diff --git a/package.json b/package.json index 4dd6fd5..c6fee34 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "react-toastify": "^11.0.5", "react-tooltip": "^5.29.1", "tailwind-merge": "^2.6.0", + "uuid": "^14.0.1", "zod": "^4.1.5" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4147d95..07764df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: tailwind-merge: specifier: ^2.6.0 version: 2.6.1 + uuid: + specifier: ^14.0.1 + version: 14.0.1 zod: specifier: ^4.1.5 version: 4.3.6 @@ -2977,6 +2980,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -6218,6 +6225,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@14.0.1: {} + uuid@8.3.2: {} verror@1.10.0: diff --git a/src/app/(home)/_components/dashboard/closing-soon.tsx b/src/app/(home)/_components/dashboard/closing-soon.tsx index d0adbcb..a365e0a 100644 --- a/src/app/(home)/_components/dashboard/closing-soon.tsx +++ b/src/app/(home)/_components/dashboard/closing-soon.tsx @@ -28,34 +28,41 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) { const rootRef = useRef(null); useLayoutEffect(() => { + const root = rootRef.current; + if (!root) return; + const reduced = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; const ctx = gsap.context(() => { if (reduced) return; - gsap.from(rootRef.current, { + gsap.from(root, { opacity: 0, y: 24, duration: 0.6, ease: "power3.out", delay: 0.3, }); - gsap.from("[data-closing-row]", { - x: 24, - opacity: 0, - duration: 0.45, - stagger: 0.07, - delay: 0.55, - ease: "power2.out", - }); - }, rootRef); + + const rows = root.querySelectorAll("[data-closing-row]"); + if (rows.length > 0) { + gsap.from(rows, { + x: 24, + opacity: 0, + duration: 0.45, + stagger: 0.07, + delay: 0.55, + ease: "power2.out", + }); + } + }, root); return () => ctx.revert(); }, [tenders.length, isLoading]); return (
@@ -66,7 +73,9 @@ export function ClosingSoonCard({ tenders, isLoading }: Props) {

Closing Soon

-

Tenders with looming deadlines

+

+ Tenders with looming deadlines +

{isLoading ? Array.from({ length: 5 }).map((_, i) => ( -
  • +
  • diff --git a/src/app/(home)/_components/dashboard/index.tsx b/src/app/(home)/_components/dashboard/index.tsx index dfd37de..ed7d510 100644 --- a/src/app/(home)/_components/dashboard/index.tsx +++ b/src/app/(home)/_components/dashboard/index.tsx @@ -53,15 +53,15 @@ export function TenderDashboard() {
    -
    + {/*
    -
    -
    +
    */} + {/*
    -
    +
    */}
    diff --git a/src/app/(home)/_components/dashboard/recent-tenders.tsx b/src/app/(home)/_components/dashboard/recent-tenders.tsx index f5100ba..3eeab15 100644 --- a/src/app/(home)/_components/dashboard/recent-tenders.tsx +++ b/src/app/(home)/_components/dashboard/recent-tenders.tsx @@ -45,34 +45,41 @@ export function RecentTenders({ tenders, isLoading }: Props) { const rootRef = useRef(null); useLayoutEffect(() => { + const root = rootRef.current; + if (!root) return; + const reduced = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; const ctx = gsap.context(() => { if (reduced) return; - gsap.from(rootRef.current, { + gsap.from(root, { opacity: 0, y: 24, duration: 0.6, ease: "power3.out", delay: 0.35, }); - gsap.from("[data-recent-row]", { - x: -24, - opacity: 0, - duration: 0.5, - stagger: 0.06, - delay: 0.55, - ease: "power2.out", - }); - }, rootRef); + + const rows = root.querySelectorAll("[data-recent-row]"); + if (rows.length > 0) { + gsap.from(rows, { + x: -24, + opacity: 0, + duration: 0.5, + stagger: 0.06, + delay: 0.55, + ease: "power2.out", + }); + } + }, root); return () => ctx.revert(); }, [tenders.length, isLoading]); return (
    diff --git a/src/components/forms/DynamicFormBuilder.tsx b/src/components/forms/DynamicFormBuilder.tsx index 9a6792f..e1caabd 100644 --- a/src/components/forms/DynamicFormBuilder.tsx +++ b/src/components/forms/DynamicFormBuilder.tsx @@ -19,6 +19,7 @@ import type { HTMLInputTypeAttribute, ReactNode, } from "react"; +import { memo } from "react"; export interface DynamicFieldContext { control: Control; @@ -61,6 +62,7 @@ export type DynamicFormField = | DynamicCustomField; export interface DynamicFormSection { + id: string; title?: ReactNode; className?: string; rootClassName?: string; @@ -90,6 +92,70 @@ const getErrorMessage = ( return typeof message === "string" ? message : undefined; }; +interface DynamicSectionProps { + section: DynamicFormSection; + register: UseFormRegister; + control: Control; + errors: FieldErrors; + footer?: ReactNode; +} + +const DynamicSectionComponent = ({ + section, + register, + control, + errors, + footer, +}: DynamicSectionProps) => ( + + {section.fields.map((field) => { + if (field.hidden) return null; + + if (field.kind === "custom") { + return ( +
    + {field.render({ + control, + errors, + register, + error: getErrorMessage(errors, field.name), + })} +
    + ); + } + + return ( + + key={field.name} + {...register(field.name, field.rules)} + name={field.name} + label={field.label} + type={field.type} + placeholder={field.placeholder} + required={field.required} + disabled={field.disabled} + autoComplete={field.autoComplete} + dataCy={field.dataCy} + icon={field.icon} + iconPosition={field.iconPosition} + className={field.className} + errors={errors} + /> + ); + })} + + {footer ?
    {footer}
    : null} +
    +); + +const DynamicSection = memo( + DynamicSectionComponent, +) as typeof DynamicSectionComponent; + const DynamicFormBuilder = ({ sections, register, @@ -113,57 +179,19 @@ const DynamicFormBuilder = ({ {header}
    {sections.map((section, sectionIndex) => ( - - {section.fields.map((field) => { - if (field.hidden) return null; - - if (field.kind === "custom") { - return ( -
    - {field.render({ - control, - errors, - register, - error: getErrorMessage(errors, field.name), - })} -
    - ); - } - - return ( - - key={field.name} - {...register(field.name, field.rules)} - name={field.name} - label={field.label} - type={field.type} - placeholder={field.placeholder} - required={field.required} - disabled={field.disabled} - autoComplete={field.autoComplete} - dataCy={field.dataCy} - icon={field.icon} - iconPosition={field.iconPosition} - className={field.className} - errors={errors} - /> - ); - })} - - {footer && - footerPlacement === "last-section" && - sectionIndex === sections.length - 1 ? ( -
    {footer}
    - ) : null} -
    + ))}
    {footerPlacement === "form" ? footer : null} diff --git a/src/components/forms/admins/createAdminFormSections.tsx b/src/components/forms/admins/createAdminFormSections.tsx index 3104e17..be5eab2 100644 --- a/src/components/forms/admins/createAdminFormSections.tsx +++ b/src/components/forms/admins/createAdminFormSections.tsx @@ -8,8 +8,11 @@ import FileImageUploadCard from "@/components/ui/FileImageUploadCard"; import { FormErrorMessages } from "@/constants/Texts"; import type { Dispatch, SetStateAction } from "react"; import { Controller, type UseFormRegisterReturn } from "react-hook-form"; +import { v4 as uuidv4 } from "uuid"; import type { CreateAdminFormValues } from "./createAdminForm.types"; +const adminSectionId = uuidv4(); + interface CreateAdminFormSectionsOptions { editMode?: boolean; passwordFieldInputType: "password" | "text"; @@ -36,6 +39,7 @@ export const createAdminFormSections = ({ clearSelectedProfileImage, }: CreateAdminFormSectionsOptions): DynamicFormSection[] => [ { + id: adminSectionId, title: "Input Fields", fields: [ { diff --git a/src/components/forms/companies/company-categories/companyCategoryFormSections.tsx b/src/components/forms/companies/company-categories/companyCategoryFormSections.tsx index 8e739c3..fed612a 100644 --- a/src/components/forms/companies/company-categories/companyCategoryFormSections.tsx +++ b/src/components/forms/companies/company-categories/companyCategoryFormSections.tsx @@ -2,11 +2,15 @@ import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area"; import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder"; import { FormErrorMessages } from "@/constants/Texts"; import type { TCreateCompanyCategoryCredentials } from "@/lib/api"; +import { v4 as uuidv4 } from "uuid"; + +const companyCategorySectionId = uuidv4(); export const createCompanyCategoryFormSections = ( editMode?: boolean, ): DynamicFormSection[] => [ { + id: companyCategorySectionId, title: editMode ? "Edit Company Category" : "Create Company Category", className: "gap-5", fields: [ diff --git a/src/components/forms/companies/companyFormSections.tsx b/src/components/forms/companies/companyFormSections.tsx index 9ed768c..8780bc3 100644 --- a/src/components/forms/companies/companyFormSections.tsx +++ b/src/components/forms/companies/companyFormSections.tsx @@ -9,8 +9,16 @@ import { FormErrorMessages } from "@/constants/Texts"; import type { ICreateCompanyCredentials } from "@/lib/api"; import type { ILabelValue } from "@/types/shared"; import type { UseFormSetValue, UseFormWatch } from "react-hook-form"; +import { v4 as uuidv4 } from "uuid"; import CompanyDocuments from "./documents/CompanyDocuments"; +const companyInformationSectionId = uuidv4(); +const businessInformationSectionId = uuidv4(); +const settingsSectionId = uuidv4(); +const addressSectionId = uuidv4(); +const tagsSectionId = uuidv4(); +const documentsSectionId = uuidv4(); + interface CompanyFormSectionsOptions { editMode?: boolean; id?: string; @@ -79,6 +87,7 @@ export const createCompanyFormSections = ({ setValue, }: CompanyFormSectionsOptions): DynamicFormSection[] => [ { + id: companyInformationSectionId, title: "Company information", rootClassName: "lg:col-span-2", className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2", @@ -235,6 +244,7 @@ export const createCompanyFormSections = ({ ], }, { + id: businessInformationSectionId, title: "Business information", className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2", fields: [ @@ -276,6 +286,7 @@ export const createCompanyFormSections = ({ ], }, { + id: settingsSectionId, title: "Settings", className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2", fields: [ @@ -323,6 +334,7 @@ export const createCompanyFormSections = ({ ], }, { + id: addressSectionId, title: "Address", className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2", fields: [ @@ -395,6 +407,7 @@ export const createCompanyFormSections = ({ ], }, { + id: tagsSectionId, title: "Tags", className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2", fields: [ @@ -479,6 +492,7 @@ export const createCompanyFormSections = ({ ], }, { + id: documentsSectionId, title: "Documents", rootClassName: "lg:col-span-2", className: "md:grid-cols-1", diff --git a/src/components/forms/customers/customerFormSections.tsx b/src/components/forms/customers/customerFormSections.tsx index 151f25f..8109cc6 100644 --- a/src/components/forms/customers/customerFormSections.tsx +++ b/src/components/forms/customers/customerFormSections.tsx @@ -14,6 +14,9 @@ import type { ILabelValue } from "@/types/shared"; import { getEnumAsArray } from "@/utils/shared"; import type { Dispatch, SetStateAction } from "react"; import { Controller, type UseFormWatch } from "react-hook-form"; +import { v4 as uuidv4 } from "uuid"; + +const customerSectionId = uuidv4(); interface CustomerFormSectionsOptions { editMode?: boolean; @@ -31,6 +34,7 @@ export const createCustomerFormSections = ({ setShowPassword, }: CustomerFormSectionsOptions): DynamicFormSection[] => [ { + id: customerSectionId, title: "Create Customer", className: "gap-5", fields: [ diff --git a/src/components/forms/notifications/notificationFormSections.tsx b/src/components/forms/notifications/notificationFormSections.tsx index a3fa131..9a01f61 100644 --- a/src/components/forms/notifications/notificationFormSections.tsx +++ b/src/components/forms/notifications/notificationFormSections.tsx @@ -8,6 +8,9 @@ import { FormErrorMessages } from "@/constants/Texts"; import type { TCreateNotificationCredentials } from "@/lib/api/types/NotificationHistory"; import type { ILabelValue } from "@/types/shared"; import type { ChangeEvent } from "react"; +import { v4 as uuidv4 } from "uuid"; + +const notificationSectionId = uuidv4(); interface NotificationFormSectionsOptions { recipient: ILabelValue[] | null; @@ -37,6 +40,7 @@ export const createNotificationFormSections = ({ channels, }: NotificationFormSectionsOptions): DynamicFormSection[] => [ { + id: notificationSectionId, title: "Create Notification", className: "gap-5", fields: [ diff --git a/src/components/logo.tsx b/src/components/logo.tsx index ab16fd6..e01ce93 100644 --- a/src/components/logo.tsx +++ b/src/components/logo.tsx @@ -4,8 +4,9 @@ export function Logo() { return (