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,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>
);
}