feat(dashboard): refactor home page to utilize TenderDashboard component
- Replaced the previous home page structure with a simplified TenderDashboard component for improved clarity and maintainability. - Updated the .gitignore file to include IDE-specific files, ensuring a cleaner repository. - Enhanced the useTenderListPresenter to support new parsing logic for cpv_codes in search parameters. - Introduced a new utility function, buildQueryString, for better handling of query parameters in API requests.
This commit is contained in:
@@ -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
|
||||
@@ -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<HTMLSpanElement>(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 <span ref={ref} className={className}>{prefix}0{suffix}</span>;
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex size-9 items-center justify-center rounded-lg bg-orange-light/10 text-orange-light">
|
||||
<FireIcon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-dark dark:text-white">
|
||||
Closing Soon
|
||||
</h2>
|
||||
<p className="text-xs text-dark-6">Tenders with looming deadlines</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/tenders"
|
||||
className="hidden items-center gap-1 text-xs font-medium text-primary hover:underline sm:inline-flex"
|
||||
>
|
||||
View all <ArrowRightIcon className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="flex-1 divide-y divide-stroke dark:divide-stroke-dark">
|
||||
{isLoading
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<li key={i} className="flex animate-pulse items-center gap-3 px-5 py-3.5">
|
||||
<div className="size-10 rounded-lg bg-gray-2 dark:bg-dark-3" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-3/4 rounded bg-gray-2 dark:bg-dark-3" />
|
||||
<div className="h-2 w-1/2 rounded bg-gray-2 dark:bg-dark-3" />
|
||||
</div>
|
||||
<div className="h-6 w-16 rounded-full bg-gray-2 dark:bg-dark-3" />
|
||||
</li>
|
||||
))
|
||||
: tenders.map((t) => {
|
||||
const deadline = t.submission_deadline || t.tender_deadline || 0;
|
||||
const tl = timeLeft(deadline);
|
||||
const country = (t.country_code || "??").toUpperCase();
|
||||
return (
|
||||
<li key={t.id} data-closing-row>
|
||||
<Link
|
||||
href={`/tenders/${t.id}`}
|
||||
className="group flex items-center gap-3 px-5 py-3.5 transition-colors hover:bg-gray-2 dark:hover:bg-dark-2"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex size-10 shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white shadow-sm",
|
||||
tl.urgent
|
||||
? "bg-gradient-to-br from-orange-light to-error"
|
||||
: "bg-gradient-to-br from-[#5750F1] to-[#0ABEF9]",
|
||||
)}
|
||||
>
|
||||
{country.slice(0, 2)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-1 text-sm font-semibold text-dark transition-colors group-hover:text-primary dark:text-white">
|
||||
{t.title || "Untitled tender"}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-dark-6">
|
||||
<ClockIcon className="size-3" />
|
||||
{deadline
|
||||
? new Date(deadline * 1000).toLocaleDateString("en", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "No deadline"}
|
||||
{t.buyer_organization?.name && (
|
||||
<>
|
||||
<span className="opacity-50">•</span>
|
||||
<span className="truncate">
|
||||
{t.buyer_organization.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2.5 py-1 text-[11px] font-semibold tabular-nums",
|
||||
tl.urgent
|
||||
? "bg-orange-light/15 text-orange-light"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{tl.label}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!isLoading && tenders.length === 0 && (
|
||||
<li className="px-5 py-10 text-center text-sm text-dark-6">
|
||||
No upcoming deadlines.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const arcsRef = useRef<SVGGElement>(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<SVGCircleElement>("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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card md:p-6"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<GlobeIcon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-dark dark:text-white">
|
||||
By Country
|
||||
</h2>
|
||||
<p className="text-xs text-dark-6">Top originating regions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-5 md:flex-row md:items-start">
|
||||
<div className="relative shrink-0">
|
||||
<svg width={(RADIUS + STROKE) * 2} height={(RADIUS + STROKE) * 2}>
|
||||
<g
|
||||
ref={arcsRef}
|
||||
transform={`translate(${RADIUS + STROKE} ${RADIUS + STROKE}) rotate(-90)`}
|
||||
>
|
||||
<circle
|
||||
r={(RADIUS + INNER) / 2}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.08"
|
||||
strokeWidth={STROKE}
|
||||
className="text-dark-6"
|
||||
/>
|
||||
{segments.map((s, i) => (
|
||||
<circle
|
||||
key={s.code}
|
||||
className="arc"
|
||||
r={(RADIUS + INNER) / 2}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={STROKE}
|
||||
strokeDasharray={s.dash}
|
||||
strokeDashoffset={s.offset}
|
||||
strokeLinecap="butt"
|
||||
style={{ filter: `drop-shadow(0 0 6px ${s.color}40)` }}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
||||
Total
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-dark dark:text-white">
|
||||
{isLoading ? "—" : totalShown}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex-1 space-y-2">
|
||||
{(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 (
|
||||
<li
|
||||
key={s.code}
|
||||
data-country-row
|
||||
className="group flex items-center gap-3 rounded-lg px-2 py-1.5 transition-colors hover:bg-gray-2 dark:hover:bg-dark-2"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-2.5 shrink-0 rounded-full",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: s.color,
|
||||
boxShadow: `0 0 0 3px ${s.color}22`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-base leading-none">
|
||||
{flagEmoji(s.code)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm font-medium text-dark dark:text-white">
|
||||
{s.code.toUpperCase()}
|
||||
</span>
|
||||
<div className="hidden h-1.5 w-24 overflow-hidden rounded-full bg-gray-2 dark:bg-dark-3 sm:block">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-700"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: s.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10 text-right text-xs font-semibold tabular-nums text-dark-5 dark:text-dark-6">
|
||||
{isLoading ? "—" : `${pct}%`}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!isLoading && segments.length === 0 && (
|
||||
<li className="text-center text-sm text-dark-6">No data yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<T>(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<T>(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;
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const orbARef = useRef<HTMLDivElement>(null);
|
||||
const orbBRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(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<HTMLElement>("[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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="relative overflow-hidden rounded-2xl border border-stroke-dark/60 bg-gradient-to-br from-dark via-dark-2 to-dark-3 p-6 text-white shadow-1 dark:border-stroke-dark dark:from-[#0B121F] dark:via-dark dark:to-dark-2 dark:shadow-card md:p-8"
|
||||
>
|
||||
<div
|
||||
ref={orbARef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-16 -top-24 h-72 w-72 rounded-full bg-primary/45 blur-3xl"
|
||||
/>
|
||||
<div
|
||||
ref={orbBRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -bottom-24 -left-10 h-64 w-64 rounded-full bg-blue/35 blur-3xl"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.07]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, white 1px, transparent 0)",
|
||||
backgroundSize: "18px 18px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<div className="flex-1">
|
||||
<span
|
||||
data-hero-pill
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs font-medium uppercase tracking-wider backdrop-blur-sm"
|
||||
>
|
||||
<SparkleIcon className="size-3.5" />
|
||||
Live tender intelligence
|
||||
</span>
|
||||
|
||||
<h1
|
||||
ref={titleRef}
|
||||
className="mt-4 text-3xl font-bold leading-tight md:text-[40px] md:leading-[1.1]"
|
||||
aria-label={headline}
|
||||
>
|
||||
{Array.from(headline).map((ch, i) => (
|
||||
<span
|
||||
key={i}
|
||||
data-char
|
||||
className="inline-block whitespace-pre will-change-transform"
|
||||
>
|
||||
{ch}
|
||||
</span>
|
||||
))}
|
||||
</h1>
|
||||
|
||||
<p className="mt-3 max-w-xl text-sm text-white/75 md:text-base">
|
||||
{sub}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<HeroPill
|
||||
label="Tracking"
|
||||
value={total}
|
||||
suffix=" tenders"
|
||||
loading={isLoading}
|
||||
accent="bg-white text-[#3C50E0]"
|
||||
/>
|
||||
<HeroPill
|
||||
label="Active"
|
||||
value={active}
|
||||
loading={isLoading}
|
||||
accent="bg-emerald-400/20 text-emerald-50 border border-emerald-300/30"
|
||||
/>
|
||||
<HeroPill
|
||||
label="Closing soon"
|
||||
value={closingSoon}
|
||||
loading={isLoading}
|
||||
accent="bg-orange-400/20 text-orange-50 border border-orange-300/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LiveClock />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroPill({
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
loading,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
suffix?: string;
|
||||
loading: boolean;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-hero-pill
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-semibold shadow-sm backdrop-blur-md",
|
||||
accent,
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-wider opacity-80">
|
||||
{label}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{loading ? (
|
||||
"—"
|
||||
) : (
|
||||
<AnimatedCounter value={value} suffix={suffix ?? ""} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
type P = SVGProps<SVGSVGElement>;
|
||||
|
||||
export const TenderIcon = (props: P) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<path
|
||||
d="M7 3h7l5 5v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
/>
|
||||
<path d="M14 3v5h5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="M8.5 13.5h7M8.5 17h5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PulseIcon = (props: P) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<path
|
||||
d="M3 12h3l2-7 4 14 2-7h7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ClockIcon = (props: P) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="M12 7.5V12l3 2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CoinIcon = (props: P) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<ellipse cx="12" cy="7" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="M4 7v5c0 1.7 3.6 3 8 3s8-1.3 8-3V7M4 12v5c0 1.7 3.6 3 8 3s8-1.3 8-3v-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const GlobeIcon = (props: P) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="M3 12h18M12 3c2.5 3 4 6 4 9s-1.5 6-4 9c-2.5-3-4-6-4-9s1.5-6 4-9Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SparkleIcon = (props: P) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<path
|
||||
d="m12 3 1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="m18 16 .8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8.8-2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const FireIcon = (props: P) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<path
|
||||
d="M12 3s4 4 4 8a4 4 0 0 1-8 0c0-1.5.7-2.6 1.5-3.5C8.5 9 8 10 8 11.5a4 4 0 0 0 8 0c0-3-4-8.5-4-8.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ArrowRightIcon = (props: P) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<path
|
||||
d="M5 12h14m0 0-5-5m5 5-5 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TrendUpIcon = (props: P) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<path
|
||||
d="m3 17 6-6 4 4 8-9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 6h7v7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TrendDownIcon = (props: P) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<path
|
||||
d="m3 7 6 6 4-4 8 9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 18h7v-7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -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 (
|
||||
<div className="space-y-4 md:space-y-6 2xl:space-y-7.5">
|
||||
<DashboardHero
|
||||
total={total}
|
||||
active={active}
|
||||
closingSoon={closingSoon}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
|
||||
<TenderStatCards
|
||||
total={total}
|
||||
active={active}
|
||||
closingSoon={closingSoon}
|
||||
totalValue={totalValue}
|
||||
valueCurrency={valueCurrency}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 md:gap-6 2xl:gap-7.5">
|
||||
<div className="col-span-12 xl:col-span-8">
|
||||
<TenderTrendsChart data={trend} isLoading={isPending} />
|
||||
</div>
|
||||
<div className="col-span-12 xl:col-span-4">
|
||||
<CountryDistribution countries={countries} isLoading={isPending} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 xl:col-span-5">
|
||||
<NoticeTypeBreakdown
|
||||
noticeTypes={noticeTypes}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-12 xl:col-span-7">
|
||||
<ClosingSoonCard tenders={closingSoonList} isLoading={isPending} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12">
|
||||
<RecentTenders tenders={recent} isLoading={recentIsLoading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Date | null>(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 (
|
||||
<div className="relative shrink-0 rounded-2xl border border-white/15 bg-white/10 px-5 py-4 backdrop-blur-md">
|
||||
<div className="text-[11px] uppercase tracking-widest text-white/75">
|
||||
{now
|
||||
? now.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "—"}
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex items-baseline overflow-hidden font-mono text-3xl font-bold tabular-nums">
|
||||
{tokens.map((t) => {
|
||||
if (t.kind === "digit") {
|
||||
return <Digit key={t.slot} value={t.value} />;
|
||||
}
|
||||
if (t.kind === "colon") {
|
||||
return (
|
||||
<span
|
||||
key={t.slot}
|
||||
className="inline-block px-0.5 text-white/90"
|
||||
>
|
||||
:
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
key={t.slot}
|
||||
className="ml-2 inline-block self-end pb-1 text-xs font-semibold tracking-[0.2em] text-white/70"
|
||||
>
|
||||
{t.value}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-white/75">
|
||||
<span className="relative flex size-2">
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
Pipeline online
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Digit({ value }: { value: string }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const prev = useRef<string>(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 (
|
||||
<span
|
||||
ref={ref}
|
||||
className="relative inline-block min-w-[0.6em] text-center text-white will-change-transform"
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card md:p-6"
|
||||
>
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<span className="inline-flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<TenderIcon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-dark dark:text-white">
|
||||
Notice Type Mix
|
||||
</h2>
|
||||
<p className="text-xs text-dark-6">Distribution across form types</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{(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 (
|
||||
<li key={`${n.type}-${n.i}`} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="truncate font-medium text-dark dark:text-white">
|
||||
{isLoading ? "Loading…" : labelize(n.type)}
|
||||
</span>
|
||||
<span className="tabular-nums text-xs font-semibold text-dark-5 dark:text-dark-6">
|
||||
{isLoading ? "—" : `${n.count} · ${pct}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-2 dark:bg-dark-3">
|
||||
<div
|
||||
data-bar-fill
|
||||
data-final-width={pct}
|
||||
className={`h-full rounded-full bg-gradient-to-r ${COLORS[n.i % COLORS.length]} shadow-sm`}
|
||||
style={{ width: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!isLoading && noticeTypes.length === 0 && (
|
||||
<li className="py-6 text-center text-sm text-dark-6">
|
||||
No notice data available.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-stroke px-5 py-4 dark:border-stroke-dark">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<SparkleIcon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-dark dark:text-white">
|
||||
Recent Tenders
|
||||
</h2>
|
||||
<p className="text-xs text-dark-6">Latest entries in the system</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/tenders"
|
||||
className="hidden items-center gap-1 text-xs font-medium text-primary hover:underline sm:inline-flex"
|
||||
>
|
||||
Browse all <ArrowRightIcon className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="flex-1 divide-y divide-stroke dark:divide-stroke-dark">
|
||||
{isLoading
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex animate-pulse items-start gap-3 px-5 py-3.5"
|
||||
>
|
||||
<div className="mt-1 size-2 rounded-full bg-gray-2 dark:bg-dark-3" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-2/3 rounded bg-gray-2 dark:bg-dark-3" />
|
||||
<div className="h-2 w-1/3 rounded bg-gray-2 dark:bg-dark-3" />
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
: tenders.map((t) => {
|
||||
const ts =
|
||||
t.created_at || t.publication_date || t.notice_dispatch_at || 0;
|
||||
return (
|
||||
<li key={t.id} data-recent-row>
|
||||
<Link
|
||||
href={`/tenders/${t.id}`}
|
||||
className="group relative flex items-start gap-3 px-5 py-3.5 transition-colors hover:bg-gray-2 dark:hover:bg-dark-2"
|
||||
>
|
||||
<span className="relative mt-1.5 flex size-2 shrink-0">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded bg-gray-2 px-1.5 py-0.5 font-mono text-[10px] uppercase text-dark-5 dark:bg-dark-3 dark:text-dark-6">
|
||||
{t.country_code || "??"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase",
|
||||
statusTone(t.status),
|
||||
)}
|
||||
>
|
||||
{t.status || "—"}
|
||||
</span>
|
||||
<time className="ml-auto shrink-0 text-[11px] text-dark-6">
|
||||
{relativeTime(ts)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-1.5 line-clamp-2 text-sm font-medium text-dark transition-colors group-hover:text-primary dark:text-white">
|
||||
{t.title || "Untitled tender"}
|
||||
</p>
|
||||
{t.buyer_organization?.name && (
|
||||
<p className="mt-0.5 line-clamp-1 text-xs text-dark-6">
|
||||
{t.buyer_organization.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!isLoading && tenders.length === 0 && (
|
||||
<li className="px-5 py-10 text-center text-sm text-dark-6">
|
||||
No tenders yet.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"
|
||||
>
|
||||
<StatCard
|
||||
label="Total Tenders"
|
||||
value={total}
|
||||
Icon={TenderIcon}
|
||||
accent="from-[#5750F1] to-[#7B6CFF]"
|
||||
ring="ring-[#5750F1]/30"
|
||||
hint="In your watchlist"
|
||||
loading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Now"
|
||||
value={active}
|
||||
Icon={PulseIcon}
|
||||
accent="from-emerald-500 to-emerald-400"
|
||||
ring="ring-emerald-400/30"
|
||||
hint={`${activePct}% of total`}
|
||||
delta={activePct}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label="Closing Soon"
|
||||
value={closingSoon}
|
||||
Icon={ClockIcon}
|
||||
accent="from-orange-500 to-amber-400"
|
||||
ring="ring-orange-400/30"
|
||||
hint={`${closingPct}% in next 7 days`}
|
||||
loading={isLoading}
|
||||
urgent={closingSoon > 0}
|
||||
/>
|
||||
<StatCard
|
||||
label="Estimated Value"
|
||||
value={totalValue}
|
||||
formatter={(v) => fmtCompact(v)}
|
||||
prefix={currencySymbol(valueCurrency)}
|
||||
Icon={CoinIcon}
|
||||
accent="from-[#0ABEF9] to-[#3C50E0]"
|
||||
ring="ring-[#0ABEF9]/30"
|
||||
hint={`${valueCurrency} aggregated`}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<SVGSVGElement>) => React.JSX.Element;
|
||||
accent: string;
|
||||
ring: string;
|
||||
hint: string;
|
||||
delta?: number;
|
||||
prefix?: string;
|
||||
formatter?: (v: number) => string;
|
||||
loading: boolean;
|
||||
urgent?: boolean;
|
||||
}) {
|
||||
const cardRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-stat-card
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-2xl bg-white p-5 shadow-1 transition-shadow hover:shadow-2 dark:bg-gray-dark dark:shadow-card",
|
||||
"ring-1 ring-stroke dark:ring-stroke-dark",
|
||||
)}
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute -right-10 -top-10 size-32 rounded-full bg-gradient-to-br opacity-20 blur-2xl transition-opacity duration-500 group-hover:opacity-40",
|
||||
accent,
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-start justify-between">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex size-12 items-center justify-center rounded-xl bg-gradient-to-br text-white shadow-md ring-4",
|
||||
accent,
|
||||
ring,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
{urgent && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-orange-light/10 px-2 py-0.5 text-[11px] font-medium text-orange-light">
|
||||
<span className="relative flex size-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-orange-light opacity-75" />
|
||||
<span className="relative inline-flex size-1.5 rounded-full bg-orange-light" />
|
||||
</span>
|
||||
Action needed
|
||||
</span>
|
||||
)}
|
||||
{delta !== undefined && !urgent && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-light-7 px-2 py-0.5 text-[11px] font-medium text-green dark:bg-green/15 dark:text-green-light-3">
|
||||
<TrendUpIcon className="size-3" />
|
||||
{delta}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="text-[28px] font-bold leading-none text-dark dark:text-white">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-20 animate-pulse rounded bg-gray-2 dark:bg-dark-3" />
|
||||
) : (
|
||||
<AnimatedCounter
|
||||
value={value}
|
||||
prefix={prefix ?? ""}
|
||||
formatter={formatter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm font-medium text-dark-5 dark:text-dark-6">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-dark-6 dark:text-dark-6">
|
||||
{hint}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const pathRef = useRef<SVGPathElement>(null);
|
||||
const areaRef = useRef<SVGPathElement>(null);
|
||||
const dotsRef = useRef<SVGGElement>(null);
|
||||
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(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<SVGSVGElement>) => {
|
||||
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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="relative overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card md:p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-dark dark:text-white">
|
||||
Tender Flow — Last 14 days
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-dark-5 dark:text-dark-6">
|
||||
New tender notices received per day
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl border border-stroke px-3 py-1.5 dark:border-stroke-dark">
|
||||
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
||||
Today
|
||||
</div>
|
||||
<div className="text-base font-bold text-dark dark:text-white">
|
||||
<AnimatedCounter value={last} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stroke px-3 py-1.5 dark:border-stroke-dark">
|
||||
<div className="text-[11px] uppercase tracking-wider text-dark-6">
|
||||
Avg/day
|
||||
</div>
|
||||
<div className="text-base font-bold text-dark dark:text-white">
|
||||
<AnimatedCounter value={avg} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary sm:inline-flex">
|
||||
<TrendUpIcon className="size-3.5" />
|
||||
{dayDelta >= 0 ? "+" : ""}
|
||||
{dayDelta} vs yesterday
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 w-full overflow-x-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex h-[240px] items-center justify-center text-sm text-dark-6">
|
||||
<PulseIcon className="mr-2 size-4 animate-pulse" /> Loading flow…
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-[240px] min-w-[600px]">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-full w-full"
|
||||
style={{ overflow: "visible" }}
|
||||
onMouseMove={handleMove}
|
||||
onMouseLeave={() => setHoverIdx(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="tenderTrendFill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#5750F1" stopOpacity="0.45" />
|
||||
<stop offset="100%" stopColor="#5750F1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="tenderTrendLine" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stopColor="#5750F1" />
|
||||
<stop offset="100%" stopColor="#0ABEF9" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{[0.25, 0.5, 0.75].map((p) => (
|
||||
<line
|
||||
key={p}
|
||||
x1={PAD_L}
|
||||
x2={W - PAD_R}
|
||||
y1={PAD_T + innerH * p}
|
||||
y2={PAD_T + innerH * p}
|
||||
stroke="currentColor"
|
||||
className="text-stroke dark:text-stroke-dark"
|
||||
strokeDasharray="4 6"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
|
||||
{areaPath && (
|
||||
<path ref={areaRef} d={areaPath} fill="url(#tenderTrendFill)" />
|
||||
)}
|
||||
{linePath && (
|
||||
<path
|
||||
ref={pathRef}
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="url(#tenderTrendLine)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
|
||||
{hovered && (
|
||||
<line
|
||||
x1={hovered.x}
|
||||
x2={hovered.x}
|
||||
y1={PAD_T}
|
||||
y2={PAD_T + innerH}
|
||||
stroke="#5750F1"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="3 4"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
|
||||
<g ref={dotsRef}>
|
||||
{points.map((p, i) => {
|
||||
const isHover = hoverIdx === i;
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={isHover ? 7 : 4}
|
||||
fill={isHover ? "#5750F1" : "white"}
|
||||
stroke="#5750F1"
|
||||
strokeWidth={isHover ? 3 : 2}
|
||||
className="transition-[r] duration-150 drop-shadow-sm"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{points.map((p, i) => {
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === points.length - 1;
|
||||
if (!(isFirst || isLast || i % 2 === 0)) return null;
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={p.x}
|
||||
y={H - 12}
|
||||
textAnchor={isFirst ? "start" : isLast ? "end" : "middle"}
|
||||
className="fill-dark-6 text-[10px]"
|
||||
>
|
||||
{p.d.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{hovered && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 rounded-xl border border-stroke bg-white px-3 py-2 text-xs shadow-lg dark:border-stroke-dark dark:bg-gray-dark"
|
||||
style={{
|
||||
left: `${tooltipLeftPct}%`,
|
||||
top: `${tooltipTopPct}%`,
|
||||
transform: `translate(${tooltipTX}, ${tooltipTY})`,
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold uppercase tracking-wider text-dark-6">
|
||||
{hovered.d.label}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-1.5">
|
||||
<span className="text-lg font-bold text-dark dark:text-white tabular-nums">
|
||||
{hovered.d.count}
|
||||
</span>
|
||||
<span className="text-[11px] text-dark-6">
|
||||
{hovered.d.count === 1 ? "tender" : "tenders"}
|
||||
</span>
|
||||
</div>
|
||||
{hoverIdx !== null && hoverIdx > 0 && (
|
||||
<div className="mt-1 text-[10px] text-dark-6">
|
||||
{(() => {
|
||||
const diff =
|
||||
hovered.d.count - points[hoverIdx - 1].d.count;
|
||||
const sign = diff > 0 ? "+" : "";
|
||||
return `${sign}${diff} vs previous day`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof useDashboardData>;
|
||||
|
||||
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<string, number>();
|
||||
const noticeTypeCounts = new Map<string, number>();
|
||||
const dailyCounts = new Map<string, number>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
+3
-59
@@ -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 (
|
||||
<>
|
||||
<Suspense fallback={<OverviewCardsSkeleton />}>
|
||||
<OverviewCardsGroup />
|
||||
</Suspense>
|
||||
|
||||
<div className="mt-4 grid grid-cols-12 gap-4 md:mt-6 md:gap-6 2xl:mt-9 2xl:gap-7.5">
|
||||
<PaymentsOverview
|
||||
className="col-span-12 xl:col-span-7"
|
||||
key={extractTimeFrame("payments_overview")}
|
||||
timeFrame={extractTimeFrame("payments_overview")?.split(":")[1]}
|
||||
/>
|
||||
|
||||
<WeeksProfit
|
||||
key={extractTimeFrame("weeks_profit")}
|
||||
timeFrame={extractTimeFrame("weeks_profit")?.split(":")[1]}
|
||||
className="col-span-12 xl:col-span-5"
|
||||
/>
|
||||
|
||||
<UsedDevices
|
||||
className="col-span-12 xl:col-span-5"
|
||||
key={extractTimeFrame("used_devices")}
|
||||
timeFrame={extractTimeFrame("used_devices")?.split(":")[1]}
|
||||
/>
|
||||
|
||||
<RegionLabels />
|
||||
|
||||
<div className="col-span-12 grid min-w-0 xl:col-span-8">
|
||||
<Suspense fallback={<TopChannelsSkeleton />}>
|
||||
<TopChannels />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<ChatsCard />
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
export default function Home() {
|
||||
return <TenderDashboard />;
|
||||
}
|
||||
|
||||
@@ -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<string, any>) => {
|
||||
const normalized: Record<string, any> = {};
|
||||
|
||||
@@ -33,6 +43,14 @@ const normalizeFilterValues = (values: Record<string, any>) => {
|
||||
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<string, any>) => ({
|
||||
...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}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, any>,
|
||||
): Promise<ApiResponse<TTenderResponse>> => {
|
||||
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;
|
||||
|
||||
@@ -111,6 +111,32 @@ export const deleteEmptyKeys = (obj: Record<string, any>) => {
|
||||
}
|
||||
return newObj;
|
||||
};
|
||||
|
||||
/** Serializes params with repeated keys for arrays (e.g. cpv_codes=1&cpv_codes=2). */
|
||||
export const buildQueryString = (params: Record<string, any>): 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 = ({
|
||||
|
||||
Reference in New Issue
Block a user