diff --git a/.gitignore b/.gitignore index da90c74..61e21e4 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,12 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +#ide +.vscode +.idea +.cursor +.cursor/worktrees.json +.cursor/worktrees +.cursor/worktrees.json +.claude \ No newline at end of file diff --git a/src/app/(home)/_components/dashboard/animated-counter.tsx b/src/app/(home)/_components/dashboard/animated-counter.tsx new file mode 100644 index 0000000..0189faf --- /dev/null +++ b/src/app/(home)/_components/dashboard/animated-counter.tsx @@ -0,0 +1,59 @@ +"use client"; + +import gsap from "gsap"; +import { useLayoutEffect, useRef } from "react"; + +type Props = { + value: number; + duration?: number; + prefix?: string; + suffix?: string; + formatter?: (v: number) => string; + className?: string; +}; + +const defaultFormatter = (v: number) => + Math.round(v).toLocaleString("en-US"); + +export function AnimatedCounter({ + value, + duration = 1.4, + prefix = "", + suffix = "", + formatter = defaultFormatter, + className, +}: Props) { + const ref = useRef(null); + const proxy = useRef({ v: 0 }); + + useLayoutEffect(() => { + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + if (!ref.current) return; + if (reduced) { + ref.current.textContent = `${prefix}${formatter(value)}${suffix}`; + return; + } + + const target = proxy.current; + const tween = gsap.to(target, { + v: value, + duration, + ease: "power2.out", + onUpdate: () => { + if (ref.current) { + ref.current.textContent = `${prefix}${formatter(target.v)}${suffix}`; + } + }, + }); + + return () => { + tween.kill(); + target.v = value; + }; + }, [value, duration, prefix, suffix, formatter]); + + return {prefix}0{suffix}; +} diff --git a/src/app/(home)/_components/dashboard/closing-soon.tsx b/src/app/(home)/_components/dashboard/closing-soon.tsx new file mode 100644 index 0000000..c695c73 --- /dev/null +++ b/src/app/(home)/_components/dashboard/closing-soon.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { TTenderDetails } from "@/lib/api"; +import gsap from "gsap"; +import Link from "next/link"; +import { useLayoutEffect, useRef } from "react"; +import { ArrowRightIcon, ClockIcon, FireIcon } from "./icons"; + +type Props = { + tenders: TTenderDetails[]; + isLoading: boolean; +}; + +function timeLeft(unix: number) { + const now = Math.floor(Date.now() / 1000); + const diff = unix - now; + if (diff <= 0) return { label: "Expired", urgent: true }; + const days = Math.floor(diff / 86400); + if (days >= 1) return { label: `${days}d left`, urgent: days <= 2 }; + const hours = Math.floor(diff / 3600); + if (hours >= 1) return { label: `${hours}h left`, urgent: true }; + const minutes = Math.max(1, Math.floor(diff / 60)); + return { label: `${minutes}m left`, urgent: true }; +} + +export function ClosingSoonCard({ tenders, isLoading }: Props) { + const rootRef = useRef(null); + + useLayoutEffect(() => { + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const ctx = gsap.context(() => { + if (reduced) return; + gsap.from(rootRef.current, { + 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); + return () => ctx.revert(); + }, [tenders.length, isLoading]); + + return ( +
+
+
+ + + +
+

+ Closing Soon +

+

Tenders with looming deadlines

+
+
+ + View all + +
+ +
    + {isLoading + ? Array.from({ length: 5 }).map((_, i) => ( +
  • +
    +
    +
    +
    +
    +
    +
  • + )) + : tenders.map((t) => { + const deadline = t.submission_deadline || t.tender_deadline || 0; + const tl = timeLeft(deadline); + const country = (t.country_code || "??").toUpperCase(); + return ( +
  • + +
    + {country.slice(0, 2)} +
    +
    +

    + {t.title || "Untitled tender"} +

    +

    + + {deadline + ? new Date(deadline * 1000).toLocaleDateString("en", { + month: "short", + day: "numeric", + }) + : "No deadline"} + {t.buyer_organization?.name && ( + <> + + + {t.buyer_organization.name} + + + )} +

    +
    + + {tl.label} + + +
  • + ); + })} + {!isLoading && tenders.length === 0 && ( +
  • + No upcoming deadlines. +
  • + )} +
+
+ ); +} diff --git a/src/app/(home)/_components/dashboard/country-distribution.tsx b/src/app/(home)/_components/dashboard/country-distribution.tsx new file mode 100644 index 0000000..b90ceda --- /dev/null +++ b/src/app/(home)/_components/dashboard/country-distribution.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import gsap from "gsap"; +import { useLayoutEffect, useMemo, useRef } from "react"; +import { GlobeIcon } from "./icons"; + +type Props = { + countries: { code: string; count: number }[]; + isLoading: boolean; +}; + +const PALETTE = [ + "#5750F1", + "#0ABEF9", + "#22AD5C", + "#F59460", + "#F23030", + "#FCD34D", + "#8155FF", +]; + +const RADIUS = 56; +const INNER = 38; +const CIRC = 2 * Math.PI * ((RADIUS + INNER) / 2); +const STROKE = RADIUS - INNER; + +function flagEmoji(code: string) { + if (!code || code.length !== 2) return ""; + const base = 0x1f1e6; + return String.fromCodePoint( + base + code.toUpperCase().charCodeAt(0) - 65, + base + code.toUpperCase().charCodeAt(1) - 65, + ); +} + +export function CountryDistribution({ countries, isLoading }: Props) { + const rootRef = useRef(null); + const arcsRef = useRef(null); + const top = countries.slice(0, 6); + const totalShown = top.reduce((acc, c) => acc + c.count, 0) || 1; + + const segments = useMemo(() => { + let offset = 0; + return top.map((c, i) => { + const length = (c.count / totalShown) * CIRC; + const seg = { + code: c.code, + count: c.count, + color: PALETTE[i % PALETTE.length], + dash: `${length} ${CIRC - length}`, + offset: -offset, + }; + offset += length; + return seg; + }); + }, [top, totalShown]); + + useLayoutEffect(() => { + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const ctx = gsap.context(() => { + if (reduced) return; + gsap.from(rootRef.current, { + opacity: 0, + y: 24, + duration: 0.6, + ease: "power3.out", + delay: 0.25, + }); + + if (arcsRef.current) { + const circles = + arcsRef.current.querySelectorAll("circle.arc"); + circles.forEach((c) => { + const finalDash = c.getAttribute("stroke-dasharray") || "0 0"; + gsap.fromTo( + c, + { strokeDasharray: `0 ${CIRC}` }, + { + strokeDasharray: finalDash, + duration: 1, + ease: "power2.out", + delay: 0.45, + }, + ); + }); + } + + gsap.from("[data-country-row]", { + x: 20, + opacity: 0, + duration: 0.45, + stagger: 0.06, + delay: 0.5, + ease: "power2.out", + }); + }, rootRef); + return () => ctx.revert(); + }, [segments.length]); + + return ( +
+
+
+ + + +
+

+ By Country +

+

Top originating regions

+
+
+
+ +
+
+ + + + {segments.map((s, i) => ( + + ))} + + +
+
+ Total +
+
+ {isLoading ? "—" : totalShown} +
+
+
+ +
    + {(isLoading + ? Array.from({ length: 5 }).map((_, i) => ({ + code: `--${i}`, + count: 0, + color: PALETTE[i], + })) + : segments.length + ? segments + : [] + ).map((s, i) => { + const pct = isLoading + ? 0 + : Math.round((s.count / totalShown) * 100); + return ( +
  • + + + {flagEmoji(s.code)} + + + {s.code.toUpperCase()} + +
    +
    +
    + + {isLoading ? "—" : `${pct}%`} + +
  • + ); + })} + {!isLoading && segments.length === 0 && ( +
  • No data yet.
  • + )} +
+
+
+ ); +} diff --git a/src/app/(home)/_components/dashboard/fake-data.ts b/src/app/(home)/_components/dashboard/fake-data.ts new file mode 100644 index 0000000..648d2a5 --- /dev/null +++ b/src/app/(home)/_components/dashboard/fake-data.ts @@ -0,0 +1,168 @@ +import { TTenderDetails } from "@/lib/api"; + +const NOW = Math.floor(Date.now() / 1000); +const DAY = 86_400; +const HOUR = 3600; + +const COUNTRIES = [ + "DE", + "FR", + "IT", + "ES", + "NL", + "BE", + "PT", + "PL", + "SE", + "AT", + "AE", + "GB", +]; + +const NOTICE_TYPES = [ + "CONTRACT_NOTICE", + "PRIOR_INFORMATION", + "CONTRACT_AWARD", + "MODIFICATION", + "CONCESSION", + "DESIGN_CONTEST", +]; + +const FORM_TYPES = ["PLANNING", "COMPETITION", "RESULT", "DIRECTIVE"]; + +const STATUSES = [ + "active", + "active", + "active", + "active", + "awarded", + "awarded", + "expired", + "cancelled", + "draft", +]; + +const BUYERS = [ + "Ministerio de Hacienda", + "Comune di Milano", + "City of Rotterdam", + "Bundesministerium für Verkehr", + "Région Île-de-France", + "Stockholm Stad", + "Generalitat de Catalunya", + "Wiener Stadtwerke", + "Servicio Andaluz de Salud", + "Dubai Municipality", + "Camara Municipal de Lisboa", + "Polska Grupa Energetyczna", + "Ayuntamiento de Madrid", + "Deutsche Bahn AG", + "Aéroports de Paris", +]; + +const TITLES = [ + "Construction of intermodal logistics hub", + "Supply of energy-efficient LED street lighting", + "Modernization of municipal water treatment plant", + "IT consultancy for public sector cloud migration", + "Renovation of historic city center facades", + "Procurement of electric public transit buses", + "Maintenance services for highway infrastructure", + "Hospital medical equipment and consumables", + "Smart city traffic monitoring system rollout", + "Cybersecurity audit and penetration testing", + "Educational software licenses for public schools", + "Green roof installation across municipal buildings", + "Catering services for government cafeterias", + "Construction supervision of metro line extension", + "Renewable energy plant feasibility study", + "Digital archive platform for national library", + "Waste collection and recycling vehicles supply", + "Emergency response communications upgrade", + "Cleaning and sanitation services framework", + "Urban park redevelopment design contest", +]; + +const CURRENCIES = ["EUR", "EUR", "EUR", "USD", "GBP", "AED"]; + +function rand(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function pick(arr: T[]): T { + return arr[rand(0, arr.length - 1)]; +} + +/** + * Deterministic seed so the dashboard looks stable across reloads + * within the same calendar day (titles/timestamps shuffle once per day). + */ +function seededShuffle(arr: T[], seed: number) { + const out = [...arr]; + let s = seed; + for (let i = out.length - 1; i > 0; i--) { + s = (s * 9301 + 49297) % 233280; + const j = Math.floor((s / 233280) * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +export function buildFakeTenders(count = 80): TTenderDetails[] { + const today = new Date(); + const seed = + today.getFullYear() * 10000 + + (today.getMonth() + 1) * 100 + + today.getDate(); + + const titlePool = seededShuffle(TITLES, seed); + const buyerPool = seededShuffle(BUYERS, seed + 1); + + const tenders: TTenderDetails[] = []; + + for (let i = 0; i < count; i++) { + const country = COUNTRIES[i % COUNTRIES.length]; + const isUrgentDeadline = i < 8; + const isFuture = i < 30; + const status = isUrgentDeadline ? "active" : pick(STATUSES); + + const created = NOW - rand(0, 14) * DAY - rand(0, 23) * HOUR; + const published = created - rand(0, 2) * DAY; + const submissionDeadline = isUrgentDeadline + ? NOW + rand(2, 6) * HOUR + i * DAY * 0.3 + : isFuture + ? NOW + rand(2, 21) * DAY + : NOW - rand(1, 60) * DAY; + const tenderDeadline = submissionDeadline + rand(0, 5) * DAY; + + tenders.push({ + id: `fake-${i + 1}`, + notice_publication_id: `PUB-${100000 + i}`, + tender_id: `T-${2026000 + i}`, + title: titlePool[i % titlePool.length], + description: + "Demonstration tender record generated for the dashboard preview. Replace with backend data when the dashboard API is wired up.", + procurement_type_code: pick(["WORKS", "SUPPLIES", "SERVICES"]), + procedure_code: pick(["OPEN", "RESTRICTED", "NEGOTIATED"]), + main_classification: "45000000", + estimated_value: rand(50_000, 12_000_000), + currency: pick(CURRENCIES), + publication_date: published, + tender_deadline: tenderDeadline, + submission_deadline: submissionDeadline, + application_deadline: submissionDeadline, + submission_url: "https://example.com/tender", + country_code: country, + buyer_organization: { + name: buyerPool[i % buyerPool.length], + contact_email: "procurement@example.com", + }, + status, + form_type: pick(FORM_TYPES), + notice_type_code: pick(NOTICE_TYPES), + created_at: created, + } as unknown as TTenderDetails); + } + + return tenders; +} diff --git a/src/app/(home)/_components/dashboard/hero.tsx b/src/app/(home)/_components/dashboard/hero.tsx new file mode 100644 index 0000000..cb6adac --- /dev/null +++ b/src/app/(home)/_components/dashboard/hero.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import gsap from "gsap"; +import { useLayoutEffect, useRef } from "react"; +import { AnimatedCounter } from "./animated-counter"; +import { SparkleIcon } from "./icons"; +import { LiveClock } from "./live-clock"; + +type Props = { + total: number; + active: number; + closingSoon: number; + isLoading: boolean; +}; + +function greeting() { + const h = new Date().getHours(); + if (h < 5) return "Working late"; + if (h < 12) return "Good morning"; + if (h < 17) return "Good afternoon"; + if (h < 21) return "Good evening"; + return "Good night"; +} + +export function DashboardHero({ total, active, closingSoon, isLoading }: Props) { + const rootRef = useRef(null); + const orbARef = useRef(null); + const orbBRef = useRef(null); + const titleRef = useRef(null); + + useLayoutEffect(() => { + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const ctx = gsap.context(() => { + if (reduced) return; + + gsap.from(rootRef.current, { + opacity: 0, + y: 24, + duration: 0.7, + ease: "power3.out", + }); + + const chars = titleRef.current?.querySelectorAll("[data-char]"); + if (chars && chars.length) { + gsap.from(chars, { + y: 28, + opacity: 0, + rotateX: -50, + duration: 0.6, + stagger: 0.025, + ease: "back.out(1.6)", + delay: 0.15, + }); + } + + gsap.from("[data-hero-pill]", { + y: 12, + opacity: 0, + duration: 0.5, + stagger: 0.08, + delay: 0.4, + ease: "power2.out", + }); + + gsap.to(orbARef.current, { + x: 30, + y: -20, + scale: 1.15, + duration: 6, + repeat: -1, + yoyo: true, + ease: "sine.inOut", + }); + gsap.to(orbBRef.current, { + x: -25, + y: 18, + scale: 1.1, + duration: 7, + repeat: -1, + yoyo: true, + ease: "sine.inOut", + }); + }, rootRef); + + return () => ctx.revert(); + }, []); + + const headline = `${greeting()}, welcome to OppLens`; + const sub = "Real-time pulse on every tender flowing through your pipeline."; + + return ( +
+
+
+
+ +
+
+ + + Live tender intelligence + + +

+ {Array.from(headline).map((ch, i) => ( + + {ch} + + ))} +

+ +

+ {sub} +

+ +
+ + + +
+
+ + +
+
+ ); +} + +function HeroPill({ + label, + value, + suffix, + loading, + accent, +}: { + label: string; + value: number; + suffix?: string; + loading: boolean; + accent: string; +}) { + return ( +
+ + {label} + + + {loading ? ( + "—" + ) : ( + + )} + +
+ ); +} diff --git a/src/app/(home)/_components/dashboard/icons.tsx b/src/app/(home)/_components/dashboard/icons.tsx new file mode 100644 index 0000000..da0e784 --- /dev/null +++ b/src/app/(home)/_components/dashboard/icons.tsx @@ -0,0 +1,144 @@ +import type { SVGProps } from "react"; + +type P = SVGProps; + +export const TenderIcon = (props: P) => ( + + + + + +); + +export const PulseIcon = (props: P) => ( + + + +); + +export const ClockIcon = (props: P) => ( + + + + +); + +export const CoinIcon = (props: P) => ( + + + + +); + +export const GlobeIcon = (props: P) => ( + + + + +); + +export const SparkleIcon = (props: P) => ( + + + + +); + +export const FireIcon = (props: P) => ( + + + +); + +export const ArrowRightIcon = (props: P) => ( + + + +); + +export const TrendUpIcon = (props: P) => ( + + + + +); + +export const TrendDownIcon = (props: P) => ( + + + + +); diff --git a/src/app/(home)/_components/dashboard/index.tsx b/src/app/(home)/_components/dashboard/index.tsx new file mode 100644 index 0000000..8d5c817 --- /dev/null +++ b/src/app/(home)/_components/dashboard/index.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { ClosingSoonCard } from "./closing-soon"; +import { CountryDistribution } from "./country-distribution"; +import { DashboardHero } from "./hero"; +import { NoticeTypeBreakdown } from "./notice-types"; +import { RecentTenders } from "./recent-tenders"; +import { TenderStatCards } from "./stat-cards"; +import { TenderTrendsChart } from "./trends-chart"; +import { useDashboardData } from "./use-dashboard-data"; + +export function TenderDashboard() { + const { + isPending, + total, + active, + closingSoon, + totalValue, + valueCurrency, + countries, + noticeTypes, + trend, + recent, + recentIsLoading, + closingSoonList, + } = useDashboardData(); + + return ( +
+ + + + +
+
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/src/app/(home)/_components/dashboard/live-clock.tsx b/src/app/(home)/_components/dashboard/live-clock.tsx new file mode 100644 index 0000000..4446f1d --- /dev/null +++ b/src/app/(home)/_components/dashboard/live-clock.tsx @@ -0,0 +1,133 @@ +"use client"; + +import gsap from "gsap"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; + +type Token = + | { kind: "digit"; value: string; slot: string } + | { kind: "colon"; slot: string } + | { kind: "period"; value: string; slot: string }; + +function tokenize(date: Date): Token[] { + const hh = date.getHours(); + const h12 = ((hh + 11) % 12) + 1; + const period = hh >= 12 ? "PM" : "AM"; + const mm = String(date.getMinutes()).padStart(2, "0"); + const ss = String(date.getSeconds()).padStart(2, "0"); + const hStr = String(h12).padStart(2, "0"); + + return [ + { kind: "digit", value: hStr[0], slot: "h1" }, + { kind: "digit", value: hStr[1], slot: "h2" }, + { kind: "colon", slot: "c1" }, + { kind: "digit", value: mm[0], slot: "m1" }, + { kind: "digit", value: mm[1], slot: "m2" }, + { kind: "colon", slot: "c2" }, + { kind: "digit", value: ss[0], slot: "s1" }, + { kind: "digit", value: ss[1], slot: "s2" }, + { kind: "period", value: period, slot: "p" }, + ]; +} + +export function LiveClock() { + const [now, setNow] = useState(null); + + useEffect(() => { + setNow(new Date()); + const id = window.setInterval(() => setNow(new Date()), 1000); + return () => window.clearInterval(id); + }, []); + + const tokens = now ? tokenize(now) : tokenize(new Date()); + + return ( +
+
+ {now + ? now.toLocaleDateString("en-US", { + weekday: "long", + month: "short", + day: "numeric", + }) + : "—"} +
+ +
+ {tokens.map((t) => { + if (t.kind === "digit") { + return ; + } + if (t.kind === "colon") { + return ( + + : + + ); + } + return ( + + {t.value} + + ); + })} +
+ +
+ + + + Pipeline online +
+
+ ); +} + +function Digit({ value }: { value: string }) { + const ref = useRef(null); + const prev = useRef(value); + + useLayoutEffect(() => { + if (!ref.current) return; + if (prev.current === value) return; + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduced) { + prev.current = value; + return; + } + + gsap.fromTo( + ref.current, + { + y: 22, + opacity: 0, + rotateX: -85, + filter: "blur(4px)", + }, + { + y: 0, + opacity: 1, + rotateX: 0, + filter: "blur(0px)", + duration: 0.55, + ease: "back.out(2)", + }, + ); + prev.current = value; + }, [value]); + + return ( + + {value} + + ); +} diff --git a/src/app/(home)/_components/dashboard/notice-types.tsx b/src/app/(home)/_components/dashboard/notice-types.tsx new file mode 100644 index 0000000..5da9025 --- /dev/null +++ b/src/app/(home)/_components/dashboard/notice-types.tsx @@ -0,0 +1,119 @@ +"use client"; + +import gsap from "gsap"; +import { useLayoutEffect, useMemo, useRef } from "react"; +import { TenderIcon } from "./icons"; + +type Props = { + noticeTypes: { type: string; count: number }[]; + isLoading: boolean; +}; + +const COLORS = [ + "from-[#5750F1] to-[#7B6CFF]", + "from-[#0ABEF9] to-[#3C50E0]", + "from-emerald-500 to-emerald-400", + "from-orange-light to-amber-400", + "from-[#F23030] to-[#F56060]", + "from-[#8155FF] to-[#0ABEF9]", +]; + +function labelize(s: string) { + return s + .replace(/_/g, " ") + .toLowerCase() + .replace(/(^|\s)\w/g, (m) => m.toUpperCase()); +} + +export function NoticeTypeBreakdown({ noticeTypes, isLoading }: Props) { + const rootRef = useRef(null); + const total = useMemo( + () => noticeTypes.reduce((a, b) => a + b.count, 0) || 1, + [noticeTypes], + ); + + useLayoutEffect(() => { + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const ctx = gsap.context(() => { + if (reduced) return; + gsap.from(rootRef.current, { + opacity: 0, + y: 24, + duration: 0.6, + ease: "power3.out", + delay: 0.4, + }); + gsap.fromTo( + "[data-bar-fill]", + { width: 0 }, + { + width: (i, el) => el.getAttribute("data-final-width") + "%", + duration: 0.9, + stagger: 0.07, + delay: 0.6, + ease: "power2.out", + }, + ); + }, rootRef); + return () => ctx.revert(); + }, [noticeTypes.length]); + + return ( +
+
+ + + +
+

+ Notice Type Mix +

+

Distribution across form types

+
+
+ +
    + {(isLoading + ? Array.from({ length: 5 }).map((_, i) => ({ + type: "—", + count: 0, + i, + })) + : noticeTypes.map((n, i) => ({ ...n, i })) + ).map((n) => { + const pct = isLoading ? 0 : Math.round((n.count / total) * 100); + return ( +
  • +
    + + {isLoading ? "Loading…" : labelize(n.type)} + + + {isLoading ? "—" : `${n.count} · ${pct}%`} + +
    +
    +
    +
    +
  • + ); + })} + {!isLoading && noticeTypes.length === 0 && ( +
  • + No notice data available. +
  • + )} +
+
+ ); +} diff --git a/src/app/(home)/_components/dashboard/recent-tenders.tsx b/src/app/(home)/_components/dashboard/recent-tenders.tsx new file mode 100644 index 0000000..f5100ba --- /dev/null +++ b/src/app/(home)/_components/dashboard/recent-tenders.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { TTenderDetails } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import gsap from "gsap"; +import Link from "next/link"; +import { useLayoutEffect, useRef } from "react"; +import { ArrowRightIcon, SparkleIcon } from "./icons"; + +type Props = { + tenders: TTenderDetails[]; + isLoading: boolean; +}; + +function relativeTime(unix: number) { + if (!unix) return "—"; + const diff = Math.floor(Date.now() / 1000) - unix; + if (diff < 60) return "just now"; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + const days = Math.floor(diff / 86400); + if (days < 30) return `${days}d ago`; + return new Date(unix * 1000).toLocaleDateString(); +} + +function statusTone(status: string) { + const s = (status || "").toLowerCase(); + switch (s) { + case "active": + return "bg-green-light-7 text-green dark:bg-green/20 dark:text-green-light-3"; + case "awarded": + return "bg-blue-light-5 text-blue dark:bg-blue/20 dark:text-blue-light-3"; + case "expired": + return "bg-orange-light/10 text-orange-light"; + case "cancelled": + return "bg-error-light-5 text-error dark:bg-error/20 dark:text-error-light"; + case "draft": + return "bg-gray-2 text-dark-5 dark:bg-dark-3 dark:text-dark-6"; + default: + return "bg-gray-2 text-dark-5 dark:bg-dark-3 dark:text-dark-6"; + } +} + +export function RecentTenders({ tenders, isLoading }: Props) { + const rootRef = useRef(null); + + useLayoutEffect(() => { + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const ctx = gsap.context(() => { + if (reduced) return; + gsap.from(rootRef.current, { + 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); + return () => ctx.revert(); + }, [tenders.length, isLoading]); + + return ( +
+
+
+ + + +
+

+ Recent Tenders +

+

Latest entries in the system

+
+
+ + Browse all + +
+ +
    + {isLoading + ? Array.from({ length: 5 }).map((_, i) => ( +
  • +
    +
    +
    +
    +
    +
  • + )) + : tenders.map((t) => { + const ts = + t.created_at || t.publication_date || t.notice_dispatch_at || 0; + return ( +
  • + + + + + +
    +
    + + {t.country_code || "??"} + + + {t.status || "—"} + + +
    +

    + {t.title || "Untitled tender"} +

    + {t.buyer_organization?.name && ( +

    + {t.buyer_organization.name} +

    + )} +
    + +
  • + ); + })} + {!isLoading && tenders.length === 0 && ( +
  • + No tenders yet. +
  • + )} +
+
+ ); +} diff --git a/src/app/(home)/_components/dashboard/stat-cards.tsx b/src/app/(home)/_components/dashboard/stat-cards.tsx new file mode 100644 index 0000000..d481ed4 --- /dev/null +++ b/src/app/(home)/_components/dashboard/stat-cards.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import gsap from "gsap"; +import { useLayoutEffect, useRef } from "react"; +import { AnimatedCounter } from "./animated-counter"; +import { + ClockIcon, + CoinIcon, + PulseIcon, + TenderIcon, + TrendUpIcon, +} from "./icons"; + +type Props = { + total: number; + active: number; + closingSoon: number; + totalValue: number; + valueCurrency: string; + isLoading: boolean; +}; + +const compact = new Intl.NumberFormat("en", { + notation: "compact", + compactDisplay: "short", + maximumFractionDigits: 1, +}); + +const fmtCompact = (v: number) => compact.format(Math.max(0, v)); + +export function TenderStatCards({ + total, + active, + closingSoon, + totalValue, + valueCurrency, + isLoading, +}: Props) { + const rootRef = useRef(null); + + useLayoutEffect(() => { + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const ctx = gsap.context(() => { + if (reduced) return; + gsap.from("[data-stat-card]", { + y: 28, + opacity: 0, + duration: 0.6, + stagger: 0.08, + ease: "power3.out", + delay: 0.1, + }); + }, rootRef); + return () => ctx.revert(); + }, []); + + const activePct = total > 0 ? Math.round((active / total) * 100) : 0; + const closingPct = total > 0 ? Math.round((closingSoon / total) * 100) : 0; + + return ( +
+ + + 0} + /> + fmtCompact(v)} + prefix={currencySymbol(valueCurrency)} + Icon={CoinIcon} + accent="from-[#0ABEF9] to-[#3C50E0]" + ring="ring-[#0ABEF9]/30" + hint={`${valueCurrency} aggregated`} + loading={isLoading} + /> +
+ ); +} + +function currencySymbol(code: string) { + switch (code) { + case "EUR": + return "€"; + case "USD": + return "$"; + case "GBP": + return "£"; + case "AED": + return "د.إ "; + default: + return ""; + } +} + +function StatCard({ + label, + value, + Icon, + accent, + ring, + hint, + delta, + prefix, + formatter, + loading, + urgent, +}: { + label: string; + value: number; + Icon: (props: React.SVGProps) => React.JSX.Element; + accent: string; + ring: string; + hint: string; + delta?: number; + prefix?: string; + formatter?: (v: number) => string; + loading: boolean; + urgent?: boolean; +}) { + const cardRef = useRef(null); + + useLayoutEffect(() => { + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduced || !cardRef.current) return; + const el = cardRef.current; + + const onMove = (e: MouseEvent) => { + const r = el.getBoundingClientRect(); + const px = (e.clientX - r.left) / r.width - 0.5; + const py = (e.clientY - r.top) / r.height - 0.5; + gsap.to(el, { + rotateY: px * 6, + rotateX: -py * 6, + transformPerspective: 700, + duration: 0.4, + ease: "power2.out", + }); + }; + const onLeave = () => { + gsap.to(el, { + rotateY: 0, + rotateX: 0, + duration: 0.5, + ease: "power3.out", + }); + }; + + el.addEventListener("mousemove", onMove); + el.addEventListener("mouseleave", onLeave); + return () => { + el.removeEventListener("mousemove", onMove); + el.removeEventListener("mouseleave", onLeave); + }; + }, []); + + return ( +
+
+
+
+ +
+ {urgent && ( + + + + + + Action needed + + )} + {delta !== undefined && !urgent && ( + + + {delta}% + + )} +
+ +
+
+ {loading ? ( + + ) : ( + + )} +
+
+ {label} +
+
+ {hint} +
+
+
+ ); +} diff --git a/src/app/(home)/_components/dashboard/trends-chart.tsx b/src/app/(home)/_components/dashboard/trends-chart.tsx new file mode 100644 index 0000000..185912f --- /dev/null +++ b/src/app/(home)/_components/dashboard/trends-chart.tsx @@ -0,0 +1,328 @@ +"use client"; + +import gsap from "gsap"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { AnimatedCounter } from "./animated-counter"; +import { PulseIcon, TrendUpIcon } from "./icons"; + +type Point = { date: string; label: string; count: number }; + +type Props = { + data: Point[]; + isLoading: boolean; +}; + +const W = 1000; +const H = 240; +const PAD_L = 0; +const PAD_R = 0; +const PAD_T = 24; +const PAD_B = 36; + +export function TenderTrendsChart({ data, isLoading }: Props) { + const rootRef = useRef(null); + const svgRef = useRef(null); + const pathRef = useRef(null); + const areaRef = useRef(null); + const dotsRef = useRef(null); + + const [hoverIdx, setHoverIdx] = useState(null); + + const { linePath, areaPath, points, sumY } = useMemo(() => { + const max = Math.max(1, ...data.map((d) => d.count)); + const innerW = W - PAD_L - PAD_R; + const innerH = H - PAD_T - PAD_B; + const step = data.length > 1 ? innerW / (data.length - 1) : innerW; + + const pts = data.map((d, i) => ({ + x: PAD_L + i * step, + y: PAD_T + innerH - (d.count / max) * innerH, + d, + })); + + if (pts.length === 0) { + return { linePath: "", areaPath: "", points: pts, sumY: 0 }; + } + + const line = pts + .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`) + .join(" "); + + const area = `${line} L ${pts[pts.length - 1].x.toFixed(2)} ${PAD_T + innerH} L ${pts[0].x.toFixed(2)} ${PAD_T + innerH} Z`; + + return { + linePath: line, + areaPath: area, + points: pts, + sumY: data.reduce((acc, p) => acc + p.count, 0), + }; + }, [data]); + + useLayoutEffect(() => { + const reduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const ctx = gsap.context(() => { + if (reduced) return; + gsap.from(rootRef.current, { + y: 24, + opacity: 0, + duration: 0.6, + ease: "power3.out", + delay: 0.2, + }); + + if (pathRef.current && linePath) { + const length = pathRef.current.getTotalLength(); + gsap.fromTo( + pathRef.current, + { strokeDasharray: length, strokeDashoffset: length }, + { + strokeDashoffset: 0, + duration: 1.4, + ease: "power2.inOut", + delay: 0.3, + }, + ); + } + if (areaRef.current) { + gsap.fromTo( + areaRef.current, + { opacity: 0, y: 12 }, + { opacity: 1, y: 0, duration: 1, delay: 0.5, ease: "power2.out" }, + ); + } + if (dotsRef.current) { + gsap.fromTo( + dotsRef.current.querySelectorAll("circle"), + { scale: 0, transformOrigin: "center" }, + { + scale: 1, + duration: 0.4, + stagger: 0.05, + ease: "back.out(2)", + delay: 0.8, + }, + ); + } + }, rootRef); + return () => ctx.revert(); + }, [linePath]); + + const last = points[points.length - 1]?.d.count ?? 0; + const prev = points[points.length - 2]?.d.count ?? 0; + const dayDelta = last - prev; + const avg = data.length ? Math.round(sumY / data.length) : 0; + + const handleMove = (e: React.MouseEvent) => { + if (!svgRef.current || !points.length) return; + const rect = svgRef.current.getBoundingClientRect(); + const px = ((e.clientX - rect.left) / rect.width) * W; + let bestI = 0; + let bestD = Infinity; + for (let i = 0; i < points.length; i++) { + const d = Math.abs(points[i].x - px); + if (d < bestD) { + bestD = d; + bestI = i; + } + } + setHoverIdx(bestI); + }; + + const hovered = hoverIdx !== null ? points[hoverIdx] : null; + const innerH = H - PAD_T - PAD_B; + const tooltipLeftPct = hovered ? (hovered.x / W) * 100 : 0; + const tooltipTopPct = hovered ? (hovered.y / H) * 100 : 0; + const flipLeft = hovered ? hovered.x > W * 0.7 : false; + const flipRight = hovered ? hovered.x < W * 0.12 : false; + const flipDown = hovered ? hovered.y < H * 0.35 : false; + const tooltipTX = flipLeft ? "-100%" : flipRight ? "0%" : "-50%"; + const tooltipTY = flipDown ? "calc(0% + 14px)" : "calc(-100% - 14px)"; + + return ( +
+
+
+

+ Tender Flow — Last 14 days +

+

+ New tender notices received per day +

+
+
+
+
+ Today +
+
+ +
+
+
+
+ Avg/day +
+
+ +
+
+
+ + {dayDelta >= 0 ? "+" : ""} + {dayDelta} vs yesterday +
+
+
+ +
+ {isLoading ? ( +
+ Loading flow… +
+ ) : ( +
+ setHoverIdx(null)} + > + + + + + + + + + + + + {[0.25, 0.5, 0.75].map((p) => ( + + ))} + + {areaPath && ( + + )} + {linePath && ( + + )} + + {hovered && ( + + )} + + + {points.map((p, i) => { + const isHover = hoverIdx === i; + return ( + + ); + })} + + + {points.map((p, i) => { + const isFirst = i === 0; + const isLast = i === points.length - 1; + if (!(isFirst || isLast || i % 2 === 0)) return null; + return ( + + {p.d.label} + + ); + })} + + + {hovered && ( +
+
+ {hovered.d.label} +
+
+ + {hovered.d.count} + + + {hovered.d.count === 1 ? "tender" : "tenders"} + +
+ {hoverIdx !== null && hoverIdx > 0 && ( +
+ {(() => { + const diff = + hovered.d.count - points[hoverIdx - 1].d.count; + const sign = diff > 0 ? "+" : ""; + return `${sign}${diff} vs previous day`; + })()} +
+ )} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/app/(home)/_components/dashboard/use-dashboard-data.ts b/src/app/(home)/_components/dashboard/use-dashboard-data.ts new file mode 100644 index 0000000..5cf7eb8 --- /dev/null +++ b/src/app/(home)/_components/dashboard/use-dashboard-data.ts @@ -0,0 +1,166 @@ +"use client"; + +import { useTendersQuery } from "@/hooks/queries"; +import { useMemo } from "react"; +import { buildFakeTenders } from "./fake-data"; + +const NOW_SECONDS = () => Math.floor(Date.now() / 1000); +const DAY = 86_400; + +/** + * Toggle to `false` once the aggregate dashboard API documented in + * `docs/backend/dashboard-api.md` is available — the hook will then + * source summary/trend/country metrics from the backend instead of + * the bundled demo set. The "Recent Tenders" list always uses the + * real `useTendersQuery` source. + */ +const USE_FAKE_AGGREGATES = true; + +export type DashboardMetrics = ReturnType; + +export function useDashboardData() { + const { + data: recentData, + isPending: recentPending, + isError: recentError, + refetch: refetchRecent, + } = useTendersQuery({ + sort_by: "created_at", + sort_order: "desc", + offset: 0, + limit: 5, + }); + + const realRecent = recentData?.data?.tenders ?? []; + + const tenders = useMemo( + () => (USE_FAKE_AGGREGATES ? buildFakeTenders(80) : []), + [], + ); + + const isPending = USE_FAKE_AGGREGATES ? false : recentPending; + const isError = recentError; + const refetch = refetchRecent; + + const derived = useMemo(() => { + const now = NOW_SECONDS(); + const total = tenders.length; + + let active = 0; + let closingSoon = 0; + let awarded = 0; + let totalValue = 0; + let valueCurrency = "EUR"; + + const countryCounts = new Map(); + const noticeTypeCounts = new Map(); + const dailyCounts = new Map(); + + for (const t of tenders) { + const status = (t.status || "").toLowerCase(); + if (status === "active") active += 1; + if (status === "awarded") awarded += 1; + + const deadline = t.submission_deadline || t.tender_deadline || 0; + if (deadline > now && deadline - now < 7 * DAY) closingSoon += 1; + + if (typeof t.estimated_value === "number" && t.estimated_value > 0) { + totalValue += t.estimated_value; + if (t.currency) valueCurrency = t.currency; + } + + if (t.country_code) { + countryCounts.set( + t.country_code, + (countryCounts.get(t.country_code) ?? 0) + 1, + ); + } + + const noticeKey = + t.form_type || t.notice_type_code || t.procurement_type_code || "OTHER"; + noticeTypeCounts.set( + noticeKey, + (noticeTypeCounts.get(noticeKey) ?? 0) + 1, + ); + + const created = t.created_at || t.publication_date; + if (created) { + const d = new Date(created * 1000); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + dailyCounts.set(key, (dailyCounts.get(key) ?? 0) + 1); + } + } + + const countries = Array.from(countryCounts.entries()) + .map(([code, count]) => ({ code, count })) + .sort((a, b) => b.count - a.count); + + const noticeTypes = Array.from(noticeTypeCounts.entries()) + .map(([type, count]) => ({ type, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 6); + + const trendDays = 14; + const trend: { date: string; label: string; count: number }[] = []; + for (let i = trendDays - 1; i >= 0; i--) { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - i); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + trend.push({ + date: key, + label: d.toLocaleDateString("en", { month: "short", day: "numeric" }), + count: dailyCounts.get(key) ?? 0, + }); + } + + const fakeRecent = [...tenders] + .sort( + (a, b) => + (b.created_at || b.publication_date || 0) - + (a.created_at || a.publication_date || 0), + ) + .slice(0, 5); + + const closingSoonList = tenders + .filter((t) => { + const dl = t.submission_deadline || t.tender_deadline || 0; + return dl > now; + }) + .sort( + (a, b) => + (a.submission_deadline || a.tender_deadline || 0) - + (b.submission_deadline || b.tender_deadline || 0), + ) + .slice(0, 5); + + return { + total, + active, + awarded, + closingSoon, + totalValue, + valueCurrency, + countries, + noticeTypes, + trend, + fakeRecent, + closingSoonList, + }; + }, [tenders]); + + const recent = realRecent.length ? realRecent : derived.fakeRecent; + const recentIsLoading = recentPending && realRecent.length === 0; + + const { fakeRecent: _ignored, ...rest } = derived; + + return { + isPending, + isError, + refetch, + tenders, + recent, + recentIsLoading, + ...rest, + }; +} diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx index f069e2b..2c6aed4 100644 --- a/src/app/(home)/page.tsx +++ b/src/app/(home)/page.tsx @@ -1,66 +1,10 @@ -import { PaymentsOverview } from "@/components/Charts/payments-overview"; -import { UsedDevices } from "@/components/Charts/used-devices"; -import { WeeksProfit } from "@/components/Charts/weeks-profit"; -import { TopChannels } from "@/components/Tables/top-channels"; -import { TopChannelsSkeleton } from "@/components/Tables/top-channels/skeleton"; -import { createTimeFrameExtractor } from "@/utils/timeframe-extractor"; import { Metadata } from "next"; -import { Suspense } from "react"; -import { ChatsCard } from "./_components/chats-card"; -import { OverviewCardsGroup } from "./_components/overview-cards"; -import { OverviewCardsSkeleton } from "./_components/overview-cards/skeleton"; -import { RegionLabels } from "./_components/region-labels"; +import { TenderDashboard } from "./_components/dashboard"; -type PropsType = { - searchParams: Promise<{ - selected_time_frame?: string; - }>; -}; export const metadata: Metadata = { title: "Tender Dashboard", }; -export default async function Home({ searchParams }: PropsType) { - const { selected_time_frame } = await searchParams; - const extractTimeFrame = createTimeFrameExtractor(selected_time_frame); - - return ( - <> - }> - - - -
- - - - - - - - -
- }> - - -
- - - - -
- - ); +export default function Home() { + return ; } diff --git a/src/components/Tables/tenders/useTenderListPresenter.ts b/src/components/Tables/tenders/useTenderListPresenter.ts index 0e53fdc..8857abb 100644 --- a/src/components/Tables/tenders/useTenderListPresenter.ts +++ b/src/components/Tables/tenders/useTenderListPresenter.ts @@ -4,7 +4,12 @@ import { apiDefaultParams } from "@/constants/Api_Params"; import { useTendersQuery } from "@/hooks/queries"; import { useHybridPagination } from "@/hooks/useHybridPagination"; import { TCustomer } from "@/lib/api"; -import { deleteEmptyKeys, getPaginatedRowNumber, unixToDate } from "@/utils/shared"; +import { + buildQueryString, + deleteEmptyKeys, + getPaginatedRowNumber, + unixToDate, +} from "@/utils/shared"; import gsap from "gsap"; import { useIsMutating } from "@tanstack/react-query"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; @@ -16,9 +21,14 @@ const COMMA_SEPARATED_FILTER_KEYS = new Set([ "notice_types", "form_types", "classifications", - "cpv_codes", ]); +const parseCpvCodesFilter = (value: string): string[] => + value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + const normalizeFilterValues = (values: Record) => { const normalized: Record = {}; @@ -33,6 +43,14 @@ const normalizeFilterValues = (values: Record) => { continue; } + if (key === "cpv_codes") { + const codes = parseCpvCodesFilter(trimmedValue); + if (codes.length) { + normalized[key] = codes; + } + continue; + } + if (COMMA_SEPARATED_FILTER_KEYS.has(key)) { normalized[key] = trimmedValue .split(",") @@ -76,8 +94,32 @@ const buildRangeFormValue = ( return undefined; }; +const formatCpvCodesForForm = (value: unknown): string => { + if (Array.isArray(value)) { + return value.map(String).filter(Boolean).join(", "); + } + return typeof value === "string" ? value : ""; +}; + +const parseCpvCodesFromSearchParams = ( + searchParams: URLSearchParams, +): string[] | undefined => { + const values = searchParams.getAll("cpv_codes").filter(Boolean); + if (!values.length) { + return undefined; + } + + if (values.length === 1 && values[0].includes(",")) { + const legacy = parseCpvCodesFilter(values[0]); + return legacy.length ? legacy : undefined; + } + + return values; +}; + const buildTenderFilterFormValues = (params: Record) => ({ ...params, + cpv_codes: formatCpvCodesForForm(params.cpv_codes), created_at_range: buildRangeFormValue( params, "created_at_from", @@ -278,8 +320,16 @@ const useTenderListPresenter = () => { offset: 0, limit: 10, }; - for (const [key, value] of searchParams.entries()) { - newParams[key] = value; + for (const key of searchParams.keys()) { + if (key === "cpv_codes") { + continue; + } + newParams[key] = searchParams.get(key); + } + + const cpvCodes = parseCpvCodesFromSearchParams(searchParams); + if (cpvCodes) { + newParams.cpv_codes = cpvCodes; } setParams(newParams); filterFormReset(mergeTenderFilterFormState(newParams)); @@ -394,7 +444,7 @@ const useTenderListPresenter = () => { } const newParams = buildFirstPageParams({ ...merged, ...normalizedData }); const cleanedParams = deleteEmptyKeys(newParams); - const queryString = new URLSearchParams(cleanedParams).toString(); + const queryString = buildQueryString(cleanedParams); router.push(`${pathName}?${queryString}`); }; diff --git a/src/lib/api/services/tenders-service.ts b/src/lib/api/services/tenders-service.ts index f42f452..c7dc395 100644 --- a/src/lib/api/services/tenders-service.ts +++ b/src/lib/api/services/tenders-service.ts @@ -1,3 +1,4 @@ +import { serializeAxiosParams } from "@/utils/shared"; import api from "../axios"; import { API_ENDPOINTS } from "../endpoints"; import { ApiResponse } from "../types"; @@ -13,7 +14,12 @@ export const tendersService = { params?: Record, ): Promise> => { try { - return (await api.get(API_ENDPOINTS.TENDERS.READ_ALL, { params })).data; + return ( + await api.get(API_ENDPOINTS.TENDERS.READ_ALL, { + params, + paramsSerializer: serializeAxiosParams, + }) + ).data; } catch (error) { console.error("ERROR caught in Tenders Services Read all:", error); throw error; diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 6141c67..2fd667a 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -111,6 +111,32 @@ export const deleteEmptyKeys = (obj: Record) => { } return newObj; }; + +/** Serializes params with repeated keys for arrays (e.g. cpv_codes=1&cpv_codes=2). */ +export const buildQueryString = (params: Record): string => { + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === "") { + continue; + } + + if (Array.isArray(value)) { + for (const item of value) { + if (item !== undefined && item !== null && item !== "") { + searchParams.append(key, String(item)); + } + } + continue; + } + + searchParams.append(key, String(value)); + } + + return searchParams.toString(); +}; + +export const serializeAxiosParams = buildQueryString; export const isValidAlpha2CountryCode = (code: string) => /^[A-Za-z]{2}$/.test(code); export const getPaginatedRowNumber = ({