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:
AmirReza Jamali
2026-05-18 10:59:16 +03:30
parent 76b3a4afb3
commit a238edc563
18 changed files with 2293 additions and 65 deletions
@@ -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
View File
@@ -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}`);
};
+7 -1
View File
@@ -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;
+26
View File
@@ -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 = ({