From d1bc56dc602e1ddb509689338f61d3242b3f9e27 Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Wed, 13 May 2026 12:19:42 +0330 Subject: [PATCH] feat(tender-list): add GSAP for animations and enhance table functionality - Integrated GSAP for improved animations in the tender list components, enhancing user experience during data loading and transitions. - Refactored TendersTable and TenderListFilters to utilize new animation features, providing smoother interactions. - Updated TableBody and TableSkeleton components to support ref forwarding, improving flexibility in rendering and animations. - Enhanced useTenderListPresenter to manage loading states and pagination more effectively, ensuring better data handling and user feedback. --- package.json | 1 + pnpm-lock.yaml | 8 + .../Tables/tenders/TenderListFilters.tsx | 602 ++++++++++++++---- src/components/Tables/tenders/index.tsx | 260 ++++---- .../Tables/tenders/useTenderListPresenter.ts | 132 +++- src/components/ui/TableSkeleton.tsx | 38 +- src/components/ui/table.tsx | 22 +- 7 files changed, 747 insertions(+), 316 deletions(-) diff --git a/package.json b/package.json index 6639ef6..5da0195 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dayjs": "^1.11.13", "firebase": "^12.3.0", "flatpickr": "^4.6.13", + "gsap": "^3.15.0", "js-cookie": "^3.0.5", "jsvectormap": "^1.6.0", "moment": "^2.30.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b2dd33..d86c9b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: flatpickr: specifier: ^4.6.13 version: 4.6.13 + gsap: + specifier: ^3.15.0 + version: 3.15.0 js-cookie: specifier: ^3.0.5 version: 3.0.5 @@ -1751,6 +1754,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gsap@3.15.0: + resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -4937,6 +4943,8 @@ snapshots: graceful-fs@4.2.11: {} + gsap@3.15.0: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} diff --git a/src/components/Tables/tenders/TenderListFilters.tsx b/src/components/Tables/tenders/TenderListFilters.tsx index 832bc3c..48265a7 100644 --- a/src/components/Tables/tenders/TenderListFilters.tsx +++ b/src/components/Tables/tenders/TenderListFilters.tsx @@ -1,9 +1,12 @@ -import { GlobeIcon } from "@/assets/icons"; +"use client"; + +import { ChevronUpIcon, GlobeIcon, SearchIcon } from "@/assets/icons"; import DatePicker from "@/components/FormElements/DatePicker/DatePicker"; import InputGroup from "@/components/FormElements/InputGroup"; import { Select } from "@/components/FormElements/select"; import { Button } from "@/components/ui-elements/button"; import { Countries } from "@/constants/countries"; +import { cn } from "@/lib/utils"; import { Control, FieldValues, @@ -12,164 +15,493 @@ import { UseFormSetValue, UseFormWatch, } from "react-hook-form"; +import gsap from "gsap"; +import { type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react"; interface TenderListFiltersProps { filterRegister: UseFormRegister; control: Control; - setIsFilterModalOpen: (isOpen: boolean) => void; isMutating: number; handleFilterSubmit: UseFormHandleSubmit; search: (data: any) => void; watch: UseFormWatch; setValue: UseFormSetValue; + onClearAll: () => void; +} + +const RANGE_KEYS = [ + "created_at_range", + "tender_deadline_range", + "publication_date_range", + "submission_deadline_range", +] as const; + +function isRangeActive(value: unknown): boolean { + if (!Array.isArray(value) || value.length === 0) return false; + return value.some((x) => { + if (x === undefined || x === null || x === "") return false; + const n = typeof x === "number" ? x : Number(x); + return Number.isFinite(n) && n > 0; + }); +} + +function countActiveFilters(values: Record): number { + let n = 0; + const textKeys = [ + "q", + "title", + "description", + "country_codes", + "notice_type", + "notice_types", + "form_types", + "main_classification", + "classifications", + "cpv_codes", + ]; + for (const key of textKeys) { + const v = values[key]; + if (typeof v === "string" && v.trim()) n++; + } + const country = values.country; + if (typeof country === "string" && country.trim()) n++; + for (const key of RANGE_KEYS) { + if (isRangeActive(values[key])) n++; + } + return n; +} + +function CollapsibleFilterCard({ + title, + subtitle, + badge, + defaultOpen, + children, +}: { + title: string; + subtitle?: string; + badge?: string; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(Boolean(defaultOpen)); + + return ( +
+ +
+
+
+ {children} +
+
+
+
+ ); } const TenderListFilters = ({ filterRegister, control, - setIsFilterModalOpen, isMutating, handleFilterSubmit, search, watch, setValue, + onClearAll, }: TenderListFiltersProps) => { + const watched = watch(); + const activeCount = useMemo(() => countActiveFilters(watched as Record), [watched]); + const [panelOpen, setPanelOpen] = useState(false); + /** When true, header stays flush (square bottom) with the panel — keep true until collapse GSAP finishes so radius does not pop early. */ + const [headerFlushBottom, setHeaderFlushBottom] = useState(false); + + const panelOuterRef = useRef(null); + const panelInnerRef = useRef(null); + const chevronRef = useRef(null); + const prevPanelOpen = useRef(panelOpen); + + /** Initial dimensions without animating (avoids flash + Strict Mode double-invoke issues). */ + useLayoutEffect(() => { + const outer = panelOuterRef.current; + const inner = panelInnerRef.current; + const chevron = chevronRef.current; + if (!outer || !inner) return; + + gsap.set(outer, { height: panelOpen ? "auto" : 0 }); + gsap.set(inner, { + opacity: panelOpen ? 1 : 0, + y: panelOpen ? 0 : -14, + scale: panelOpen ? 1 : 0.995, + }); + if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180, transformOrigin: "50% 50%" }); + if (!panelOpen) { + setHeaderFlushBottom(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount for initial `panelOpen` + }, []); + + useLayoutEffect(() => { + const outer = panelOuterRef.current; + const inner = panelInnerRef.current; + const chevron = chevronRef.current; + if (!outer || !inner) return; + + if (prevPanelOpen.current === panelOpen) return; + prevPanelOpen.current = panelOpen; + + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + gsap.killTweensOf([outer, inner, chevron].filter(Boolean)); + + if (reduced) { + gsap.set(outer, { height: panelOpen ? "auto" : 0 }); + gsap.set(inner, { opacity: panelOpen ? 1 : 0, y: panelOpen ? 0 : -10, scale: 1 }); + if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180 }); + setHeaderFlushBottom(panelOpen); + return; + } + + const ctx = gsap.context(() => { + if (panelOpen) { + setHeaderFlushBottom(true); + const targetH = inner.scrollHeight; + gsap.set(outer, { height: 0 }); + gsap.set(inner, { opacity: 0, y: -36, scale: 0.985 }); + + const tl = gsap.timeline({ defaults: { ease: "none" } }); + tl.to(outer, { + height: targetH, + duration: 0.62, + ease: "power4.out", + onComplete: () => { + gsap.set(outer, { height: "auto" }); + }, + }).to( + inner, + { + opacity: 1, + y: 0, + scale: 1, + duration: 0.52, + ease: "power3.out", + }, + "-=0.5", + ); + + if (chevron) { + tl.to( + chevron, + { rotation: 0, duration: 0.48, ease: "back.out(1.35)" }, + "-=0.52", + ); + } + } else { + const currentH = Math.max(outer.offsetHeight, inner.scrollHeight); + gsap.set(outer, { height: currentH }); + + const tl = gsap.timeline({ + onComplete: () => { + setHeaderFlushBottom(false); + }, + }); + tl.to(inner, { + opacity: 0, + y: -22, + scale: 0.992, + duration: 0.3, + ease: "power2.in", + }) + .to( + outer, + { + height: 0, + duration: 0.48, + ease: "power3.inOut", + }, + "-=0.12", + ); + if (chevron) { + tl.to(chevron, { rotation: 180, duration: 0.42, ease: "power2.inOut" }, 0); + } + } + }, outer); + + return () => ctx.revert(); + }, [panelOpen]); + return ( -
- - - +
+
+ - - {...filterRegister("country")} - name="country" - label="country" - items={Countries} - placeholder="Country" - clearable - prefixIcon={} - value={watch("country")} - onClear={() => setValue("country", "")} - /> +
+
+ + {/* Hero search */} +
+
+ } + iconPosition="left" + className="[&_input]:rounded-2xl [&_input]:border-stroke/70 [&_input]:bg-white/90 [&_input]:py-3.5 [&_input]:text-sm dark:[&_input]:border-dark-3 dark:[&_input]:bg-dark-2/80" + /> +
+
+ +
+
- - - - - - - - - - - + +
+ + {...filterRegister("country")} + name="country" + label="Country" + items={Countries} + placeholder="Any country" + clearable + prefixIcon={} + value={watch("country")} + onClear={() => setValue("country", "")} + /> + +
+
-
-
- ) : ( - Search - ) - } - /> -
- + +
+ + + + + + + + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+ ); }; diff --git a/src/components/Tables/tenders/index.tsx b/src/components/Tables/tenders/index.tsx index f30318a..3766c09 100644 --- a/src/components/Tables/tenders/index.tsx +++ b/src/components/Tables/tenders/index.tsx @@ -1,5 +1,6 @@ "use client"; +import { ExclamationIcon, EyeIcon } from "@/assets/icons"; import EmptyListWrapper from "@/components/HOC/EmptyListWrapper"; import Status from "@/components/ui/Status"; import { @@ -10,32 +11,20 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Tooltip } from "react-tooltip"; -import useTenderListPresenter from "./useTenderListPresenter"; - -import { ExclamationIcon, EyeIcon } from "@/assets/icons"; - import IsVisible from "@/components/ui/IsVisible"; import ListHeader from "@/components/ui/ListHeader"; import ListWrapper from "@/components/ui/ListWrapper"; -import Modal from "@/components/ui/modal"; import Pagination from "@/components/ui/pagination"; import TableSkeleton from "@/components/ui/TableSkeleton"; import { _TooltipDefaultParams } from "@/constants/tooltip"; import { cn } from "@/lib/utils"; -import { getPaginatedRowNumber, unixToDate } from "@/utils/shared"; +import { Tooltip } from "react-tooltip"; import TenderListFilters from "./TenderListFilters"; +import useTenderListPresenter from "./useTenderListPresenter"; + const TendersTable = () => { const { - allTenders, - isPending, - router, - pathName, - setParams, - params, columns, - isFilterModalOpen, - setIsFilterModalOpen, filterRegister, handleFilterSubmit, search, @@ -44,149 +33,130 @@ const TendersTable = () => { control, isMutating, shouldShowPagination, + clearAllFilters, + tableSectionRef, + skeletonTbodyRef, + dataTbodyRef, + tendersList, + isPending, + navigateToTenderDetails, + navigateToTenderFeedback, + handlePaginationChange, + getRowNumber, + formatDateTimeCell, + pagination, } = useTenderListPresenter(); return ( setIsFilterModalOpen(true)} + hasFilter={false} createButtonText="Create Tender" hasCreate={false} /> - - - - {columns.map((column) => ( - - {column} - - ))} - - + +
+
+ + + {columns.map((column) => ( + + {column} + + ))} + + - {isPending && } - - - {(allTenders?.data.tenders ?? []).map((item, index) => ( - + ) : ( + + - - {getPaginatedRowNumber({ - index, - page: allTenders?.meta?.page, - limit: allTenders?.meta?.limit, - })} - - - {item.title} - - - {item.country_code} - - - {unixToDate({ - unix: item.publication_date, - hasTime: true, - })} - - - {unixToDate({ - unix: item.submission_deadline, - hasTime: true, - })} - - - {unixToDate({ unix: item.tender_deadline, hasTime: true })} - - - {unixToDate({ - unix: item?.created_at ?? 0, - hasTime: true, - })} - + {tendersList.map((item, index) => ( + + + {getRowNumber(index)} + + + {item.title} + + + {item.country_code} + + + {formatDateTimeCell(item.publication_date)} + + + {formatDateTimeCell(item.submission_deadline)} + + + {formatDateTimeCell(item.tender_deadline)} + + + {formatDateTimeCell(item?.created_at ?? 0)} + - - {item.status} - - -
- - -
-
-
- ))} -
-
-
- setIsFilterModalOpen(false)} - classNames="w-full xl:w-1/2" - showButtons={false} - > - - + + {item.status} + + +
+ + +
+
+ + ))} + + + )} + + { - setParams((currentParams) => ({ - ...currentParams, - offset: e.selected * (allTenders?.meta?.limit ?? 10), - limit: allTenders?.meta?.limit ?? 10, - })); - }} + currentPage={pagination.currentPage} + totalPages={pagination.totalPages} + onPageChange={handlePaginationChange} />
diff --git a/src/components/Tables/tenders/useTenderListPresenter.ts b/src/components/Tables/tenders/useTenderListPresenter.ts index 0b67395..161db69 100644 --- a/src/components/Tables/tenders/useTenderListPresenter.ts +++ b/src/components/Tables/tenders/useTenderListPresenter.ts @@ -3,10 +3,11 @@ import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal"; import { apiDefaultParams } from "@/constants/Api_Params"; import { useTendersQuery } from "@/hooks/queries"; import { TCustomer } from "@/lib/api"; -import { deleteEmptyKeys } from "@/utils/shared"; +import { deleteEmptyKeys, getPaginatedRowNumber, unixToDate } from "@/utils/shared"; +import gsap from "gsap"; import { useIsMutating } from "@tanstack/react-query"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; const COMMA_SEPARATED_FILTER_KEYS = new Set([ @@ -216,7 +217,6 @@ const TENDER_FILTER_MODAL_KEYS = new Set([ const useTenderListPresenter = () => { const [isModalOpen, setIsModalOpen] = useState(false); - const [isFilterModalOpen, setIsFilterModalOpen] = useState(false); const [currentTender, setCurrentTender] = useState(null); const [modalConfig, setModalConfig] = useState< Partial @@ -227,6 +227,10 @@ const useTenderListPresenter = () => { const searchParams = useSearchParams(); const isMutating = useIsMutating(); + const tableSectionRef = useRef(null); + const skeletonTbodyRef = useRef(null); + const dataTbodyRef = useRef(null); + const { register: filterRegister, handleSubmit: handleFilterSubmit, @@ -253,6 +257,110 @@ const useTenderListPresenter = () => { const shouldShowPagination = !isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10; + const tendersList = data?.data?.tenders ?? []; + + const langQuerySuffix = useMemo(() => { + const lang = params?.lang; + return typeof lang === "string" && lang.trim() + ? `?lang=${encodeURIComponent(lang)}` + : ""; + }, [params?.lang]); + + const navigateToTenderDetails = useCallback( + (tenderId: string) => { + router.push(`${pathName}/${tenderId}${langQuerySuffix}`); + }, + [pathName, router, langQuerySuffix], + ); + + const navigateToTenderFeedback = useCallback( + (tenderId: string) => { + router.push(`${pathName}/feedback/${tenderId}${langQuerySuffix}`); + }, + [pathName, router, langQuerySuffix], + ); + + const handlePaginationChange = useCallback( + (e: { selected: number }) => { + const limit = data?.meta?.limit ?? 10; + setParams((currentParams) => ({ + ...currentParams, + offset: e.selected * limit, + limit, + })); + }, + [data?.meta?.limit, setParams], + ); + + const getRowNumber = useCallback( + (index: number) => + getPaginatedRowNumber({ + index, + page: data?.meta?.page, + limit: data?.meta?.limit, + }), + [data?.meta?.limit, data?.meta?.page], + ); + + const formatDateTimeCell = useCallback((unix: number) => { + return unixToDate({ unix, hasTime: true }); + }, []); + + const pagination = useMemo( + () => ({ + currentPage: data?.meta?.page ? data.meta.page - 1 : 0, + totalPages: data?.meta?.pages ?? 1, + }), + [data?.meta?.page, data?.meta?.pages], + ); + + useLayoutEffect(() => { + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const ctx = gsap.context(() => { + if (reduced) return; + + if (isPending) { + const tbody = skeletonTbodyRef.current; + if (!tbody) return; + const rows = tbody.querySelectorAll("tr"); + gsap.fromTo( + rows, + { opacity: 0.35, y: 14 }, + { + opacity: 1, + y: 0, + duration: 0.42, + stagger: 0.032, + ease: "power2.out", + }, + ); + return; + } + + const tbody = dataTbodyRef.current; + if (!tbody) return; + const rows = tbody.querySelectorAll("tr"); + if (!rows.length) return; + gsap.fromTo( + rows, + { opacity: 0, y: 18 }, + { + opacity: 1, + y: 0, + duration: 0.46, + stagger: 0.05, + ease: "power3.out", + clearProps: "opacity,transform", + }, + ); + }, tableSectionRef); + + return () => ctx.revert(); + }, [isPending, data]); + const search = (data: any) => { const mappedDateRanges = mapDateRangeFilters(data); const normalizedData = normalizeFilterValues(mappedDateRanges); @@ -266,8 +374,11 @@ const useTenderListPresenter = () => { const cleanedParams = deleteEmptyKeys(newParams); const queryString = new URLSearchParams(cleanedParams).toString(); router.push(`${pathName}?${queryString}`); - setIsFilterModalOpen(false); }; + + const clearAllFilters = useCallback(() => { + router.push(pathName); + }, [pathName, router]); const columns = [ "row", "title", @@ -295,8 +406,7 @@ const useTenderListPresenter = () => { setIsModalOpen, modalConfig, setModalConfig, - isFilterModalOpen, - setIsFilterModalOpen, + clearAllFilters, filterRegister, handleFilterSubmit, search, @@ -305,6 +415,16 @@ const useTenderListPresenter = () => { control, isMutating, shouldShowPagination, + tableSectionRef, + skeletonTbodyRef, + dataTbodyRef, + tendersList, + navigateToTenderDetails, + navigateToTenderFeedback, + handlePaginationChange, + getRowNumber, + formatDateTimeCell, + pagination, }; }; diff --git a/src/components/ui/TableSkeleton.tsx b/src/components/ui/TableSkeleton.tsx index 37c7af2..bb28403 100644 --- a/src/components/ui/TableSkeleton.tsx +++ b/src/components/ui/TableSkeleton.tsx @@ -1,3 +1,4 @@ +import * as React from "react"; import { Skeleton } from "./skeleton"; import { TableBody, TableCell, TableRow } from "./table"; @@ -7,24 +8,23 @@ interface IProps { cellColSpan?: number; } -const TableSkeleton = ({ - column, - length = 20, - cellColSpan = 100, -}: IProps) => { - return ( - - {Array.from({ length }).map((_, i) => ( - - {Array.from({ length: column }).map((_, j) => ( - - - - ))} - - ))} - - ); -}; +const TableSkeleton = React.forwardRef( + ({ column, length = 20, cellColSpan = 100 }, ref) => { + return ( + + {Array.from({ length }).map((_, i) => ( + + {Array.from({ length: column }).map((_, j) => ( + + + + ))} + + ))} + + ); + }, +); +TableSkeleton.displayName = "TableSkeleton"; export default TableSkeleton; diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index 7b35132..53aa0c4 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -30,17 +30,17 @@ export function TableHeader({ ); } -export function TableBody({ - className, - ...props -}: React.HTMLAttributes) { - return ( - - ); -} +export const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; export function TableFooter({ className,