chore(env): update application version to 2.1.2 in production environment
- Incremented the application version to 2.1.2 for production deployment. - Enhanced the DashboardHero component with additional animation effects and improved layout. - Refactored LiveClock and StatCard components for better styling and performance. - Introduced new shimmer animation in NoticeTypeBreakdown for visual enhancement. - Updated the trends chart with improved stroke effects and hover animations.
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
NEXT_PUBLIC_APP_VERSION=2.1.1
|
||||
NEXT_PUBLIC_APP_VERSION=2.1.2
|
||||
NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
|
||||
|
||||
@@ -23,70 +23,204 @@ function greeting() {
|
||||
return "Good night";
|
||||
}
|
||||
|
||||
export function DashboardHero({ total, active, closingSoon, isLoading }: Props) {
|
||||
export function DashboardHero({
|
||||
total,
|
||||
active,
|
||||
closingSoon,
|
||||
isLoading,
|
||||
}: Props) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const auroraRef = useRef<HTMLDivElement>(null);
|
||||
const orbARef = useRef<HTMLDivElement>(null);
|
||||
const orbBRef = useRef<HTMLDivElement>(null);
|
||||
const orbCRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const shineRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const resetIntroTargets = () => {
|
||||
const root = rootRef.current;
|
||||
if (root) {
|
||||
gsap.killTweensOf(root);
|
||||
gsap.set(root, { clearProps: "opacity,transform" });
|
||||
}
|
||||
const title = titleRef.current;
|
||||
if (title) {
|
||||
const inner = title.querySelector<HTMLElement>("[data-title-inner]");
|
||||
if (inner) {
|
||||
gsap.killTweensOf(inner);
|
||||
gsap.set(inner, { clearProps: "opacity,transform" });
|
||||
}
|
||||
}
|
||||
root?.querySelectorAll<HTMLElement>("[data-hero-pill]").forEach((el) => {
|
||||
gsap.killTweensOf(el);
|
||||
gsap.set(el, { clearProps: "opacity,transform,scale" });
|
||||
});
|
||||
};
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
if (reduced) return;
|
||||
|
||||
gsap.from(rootRef.current, {
|
||||
opacity: 0,
|
||||
y: 24,
|
||||
duration: 0.7,
|
||||
ease: "power3.out",
|
||||
});
|
||||
const root = rootRef.current;
|
||||
if (root) {
|
||||
// Do not tween root opacity: Strict Mode + revert can leave the whole hero
|
||||
// at opacity 0. Motion uses translateY only.
|
||||
gsap.fromTo(
|
||||
root,
|
||||
{ y: 28 },
|
||||
{
|
||||
y: 0,
|
||||
duration: 0.8,
|
||||
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,
|
||||
// Animate a wrapper around the headline, not per-glyph. Transforms on
|
||||
// children inside `bg-clip-text` / `text-transparent` often paint as fully
|
||||
// invisible in WebKit and some Chromium builds.
|
||||
const titleInner = titleRef.current?.querySelector<HTMLElement>(
|
||||
"[data-title-inner]",
|
||||
);
|
||||
if (titleInner) {
|
||||
gsap.fromTo(
|
||||
titleInner,
|
||||
{ y: 36 },
|
||||
{
|
||||
y: 0,
|
||||
duration: 0.75,
|
||||
ease: "back.out(1.7)",
|
||||
delay: 0.2,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const pills = rootRef.current?.querySelectorAll<HTMLElement>("[data-hero-pill]");
|
||||
if (pills?.length) {
|
||||
gsap.fromTo(
|
||||
pills,
|
||||
{ y: 16, scale: 0.92 },
|
||||
{
|
||||
y: 0,
|
||||
scale: 1,
|
||||
duration: 0.55,
|
||||
stagger: 0.08,
|
||||
delay: 0.55,
|
||||
ease: "back.out(1.6)",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (auroraRef.current) {
|
||||
gsap.to(auroraRef.current, {
|
||||
rotate: 360,
|
||||
duration: 42,
|
||||
ease: "none",
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
|
||||
gsap.from("[data-hero-pill]", {
|
||||
y: 12,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
stagger: 0.08,
|
||||
delay: 0.4,
|
||||
ease: "power2.out",
|
||||
});
|
||||
if (orbARef.current) {
|
||||
gsap.to(orbARef.current, {
|
||||
x: 42,
|
||||
y: -26,
|
||||
scale: 1.18,
|
||||
duration: 7,
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
ease: "sine.inOut",
|
||||
});
|
||||
}
|
||||
if (orbBRef.current) {
|
||||
gsap.to(orbBRef.current, {
|
||||
x: -32,
|
||||
y: 24,
|
||||
scale: 1.12,
|
||||
duration: 8,
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
ease: "sine.inOut",
|
||||
});
|
||||
}
|
||||
if (orbCRef.current) {
|
||||
gsap.to(orbCRef.current, {
|
||||
x: 22,
|
||||
y: 18,
|
||||
scale: 1.08,
|
||||
duration: 9.5,
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
ease: "sine.inOut",
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
if (shineRef.current) {
|
||||
const shineTl = gsap.timeline({ repeat: -1, repeatDelay: 3.5 });
|
||||
shineTl
|
||||
.set(shineRef.current, { xPercent: -160, opacity: 0 })
|
||||
.to(shineRef.current, {
|
||||
opacity: 1,
|
||||
duration: 0.2,
|
||||
delay: 1.8,
|
||||
})
|
||||
.to(
|
||||
shineRef.current,
|
||||
{
|
||||
xPercent: 260,
|
||||
duration: 1.8,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
"<",
|
||||
)
|
||||
.to(shineRef.current, { opacity: 0, duration: 0.2 });
|
||||
}
|
||||
|
||||
if (!root) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const r = root.getBoundingClientRect();
|
||||
const px = (e.clientX - r.left) / r.width - 0.5;
|
||||
const py = (e.clientY - r.top) / r.height - 0.5;
|
||||
gsap.to(orbARef.current, {
|
||||
xPercent: px * 22,
|
||||
yPercent: py * 18,
|
||||
duration: 1.2,
|
||||
ease: "power2.out",
|
||||
});
|
||||
gsap.to(orbBRef.current, {
|
||||
xPercent: px * -18,
|
||||
yPercent: py * 20,
|
||||
duration: 1.3,
|
||||
ease: "power2.out",
|
||||
});
|
||||
gsap.to(orbCRef.current, {
|
||||
xPercent: px * 12,
|
||||
yPercent: py * -14,
|
||||
duration: 1.4,
|
||||
ease: "power2.out",
|
||||
});
|
||||
};
|
||||
root.addEventListener("mousemove", onMove);
|
||||
return () => root.removeEventListener("mousemove", onMove);
|
||||
}, rootRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
return () => {
|
||||
const root = rootRef.current;
|
||||
const title = titleRef.current;
|
||||
const titleInner = title?.querySelector<HTMLElement>("[data-title-inner]");
|
||||
if (titleInner) gsap.killTweensOf(titleInner);
|
||||
if (root) gsap.killTweensOf(root);
|
||||
root
|
||||
?.querySelectorAll<HTMLElement>("[data-hero-pill]")
|
||||
.forEach((el) => gsap.killTweensOf(el));
|
||||
ctx.revert();
|
||||
// React Strict Mode remounts before intro tweens finish; revert can leave
|
||||
// inline opacity/transform stuck (invisible headline / blank hero).
|
||||
resetIntroTargets();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const headline = `${greeting()}, welcome to OppLens`;
|
||||
@@ -95,77 +229,120 @@ export function DashboardHero({ total, active, closingSoon, isLoading }: Props)
|
||||
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"
|
||||
className="relative isolate overflow-hidden rounded-3xl border border-stroke/70 bg-gradient-to-br from-slate-50 via-white to-violet-50/40 p-6 text-slate-900 shadow-1 md:p-8 lg:p-10 dark:border-transparent dark:bg-[#0A0E1E] dark:bg-none dark:text-white dark:shadow-card"
|
||||
>
|
||||
<div
|
||||
ref={auroraRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -inset-[40%] z-0 opacity-45 dark:opacity-80"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"conic-gradient(from 0deg at 50% 50%, #5750F1 0deg, #0ABEF9 80deg, transparent 140deg, #8155FF 200deg, #5750F1 280deg, transparent 320deg, #5750F1 360deg)",
|
||||
filter: "blur(64px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<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"
|
||||
className="pointer-events-none absolute -right-24 -top-32 z-0 h-80 w-80 rounded-full bg-[#5750F1]/25 blur-3xl dark:bg-[#5750F1]/55"
|
||||
/>
|
||||
<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"
|
||||
className="pointer-events-none absolute -bottom-32 -left-16 z-0 h-72 w-72 rounded-full bg-[#0ABEF9]/20 blur-3xl dark:bg-[#0ABEF9]/45"
|
||||
/>
|
||||
<div
|
||||
ref={orbCRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-1/2 top-1/3 z-0 h-56 w-56 -translate-x-1/2 rounded-full bg-[#F23030]/10 blur-3xl dark:bg-[#F23030]/20"
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0 opacity-[0.06] dark:hidden"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, rgb(15 23 42) 1px, transparent 0)",
|
||||
backgroundSize: "20px 20px",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.07]"
|
||||
className="pointer-events-none absolute inset-0 z-0 hidden opacity-[0.07] dark:block"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, white 1px, transparent 0)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundSize: "20px 20px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0 rounded-3xl ring-1 ring-inset ring-slate-900/[0.06] dark:ring-white/10"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex flex-col gap-7 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"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-slate-200/90 bg-white/90 px-3.5 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-600 backdrop-blur-xl dark:border-white/15 dark:bg-white/[0.06] dark:text-white/85"
|
||||
>
|
||||
<SparkleIcon className="size-3.5" />
|
||||
<span className="relative flex size-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-70" />
|
||||
<span className="relative inline-flex size-1.5 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
Live tender intelligence
|
||||
<SparkleIcon className="size-3.5 text-slate-500 dark:text-white/80" />
|
||||
</span>
|
||||
|
||||
<h1
|
||||
ref={titleRef}
|
||||
className="mt-4 text-3xl font-bold leading-tight md:text-[40px] md:leading-[1.1]"
|
||||
className="relative z-[1] mt-5 max-w-2xl text-3xl font-bold leading-tight md:text-[44px] md:leading-[1.05]"
|
||||
aria-label={headline}
|
||||
>
|
||||
{Array.from(headline).map((ch, i) => (
|
||||
<span
|
||||
key={i}
|
||||
data-char
|
||||
className="inline-block whitespace-pre will-change-transform"
|
||||
>
|
||||
{ch}
|
||||
<span
|
||||
data-title-inner
|
||||
className="relative z-[1] inline-block will-change-transform"
|
||||
>
|
||||
<span className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-500 bg-clip-text text-transparent dark:from-white dark:via-white dark:to-white/60">
|
||||
{headline}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0 overflow-hidden"
|
||||
>
|
||||
<span
|
||||
ref={shineRef}
|
||||
className="absolute inset-y-0 block w-1/3 opacity-0 blur-[10px] [background:linear-gradient(100deg,transparent_0%,rgba(87,80,241,0.35)_50%,transparent_100%)] dark:[background:linear-gradient(100deg,transparent_0%,rgba(255,255,255,0.45)_50%,transparent_100%)]"
|
||||
/>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-3 max-w-xl text-sm text-white/75 md:text-base">
|
||||
<p className="mt-3 max-w-xl text-sm text-slate-600 md:text-base dark:text-white/65">
|
||||
{sub}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<div className="mt-7 flex flex-wrap items-center gap-3">
|
||||
<HeroPill
|
||||
label="Tracking"
|
||||
value={total}
|
||||
suffix=" tenders"
|
||||
loading={isLoading}
|
||||
accent="bg-white text-[#3C50E0]"
|
||||
tone="primary"
|
||||
/>
|
||||
<HeroPill
|
||||
label="Active"
|
||||
value={active}
|
||||
loading={isLoading}
|
||||
accent="bg-emerald-400/20 text-emerald-50 border border-emerald-300/30"
|
||||
tone="emerald"
|
||||
/>
|
||||
<HeroPill
|
||||
label="Closing soon"
|
||||
value={closingSoon}
|
||||
loading={isLoading}
|
||||
accent="bg-orange-400/20 text-orange-50 border border-orange-300/30"
|
||||
tone="amber"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,23 +358,31 @@ function HeroPill({
|
||||
value,
|
||||
suffix,
|
||||
loading,
|
||||
accent,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
suffix?: string;
|
||||
loading: boolean;
|
||||
accent: string;
|
||||
tone: "primary" | "emerald" | "amber";
|
||||
}) {
|
||||
const tones: Record<typeof tone, string> = {
|
||||
primary:
|
||||
"bg-gradient-to-br from-white to-slate-100 text-[#1B1F3A] shadow-[0_8px_24px_-10px_rgba(15,23,42,0.2)] ring-1 ring-slate-900/5 dark:from-white/95 dark:to-white/75 dark:shadow-[0_10px_30px_-12px_rgba(255,255,255,0.45)] dark:ring-0",
|
||||
emerald:
|
||||
"border border-emerald-200/80 bg-gradient-to-br from-emerald-50 to-emerald-100/50 text-emerald-900 shadow-[0_8px_24px_-10px_rgba(16,185,129,0.25)] dark:from-emerald-400/30 dark:to-emerald-300/5 dark:text-emerald-50 dark:border-emerald-300/30 dark:shadow-[0_10px_30px_-12px_rgba(16,185,129,0.55)]",
|
||||
amber:
|
||||
"border border-orange-200/80 bg-gradient-to-br from-amber-50 to-orange-50/80 text-amber-950 shadow-[0_8px_24px_-10px_rgba(251,146,60,0.2)] dark:from-orange-400/30 dark:to-amber-300/5 dark:text-orange-50 dark:border-orange-300/30 dark:shadow-[0_10px_30px_-12px_rgba(251,146,60,0.55)]",
|
||||
};
|
||||
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,
|
||||
"group relative flex items-center gap-2 overflow-hidden rounded-full px-4 py-2 text-sm font-semibold backdrop-blur-xl transition-transform hover:scale-[1.04]",
|
||||
tones[tone],
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-wider opacity-80">
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.16em] opacity-70">
|
||||
{label}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
@@ -207,6 +392,10 @@ function HeroPill({
|
||||
<AnimatedCounter value={value} suffix={suffix ?? ""} />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/30 to-transparent transition-transform duration-700 group-hover:translate-x-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export function TenderDashboard() {
|
||||
closingSoon={closingSoon}
|
||||
totalValue={totalValue}
|
||||
valueCurrency={valueCurrency}
|
||||
trend={trend}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
|
||||
|
||||
@@ -30,29 +30,29 @@ function tokenize(date: Date): Token[] {
|
||||
}
|
||||
|
||||
export function LiveClock() {
|
||||
const [now, setNow] = useState<Date | null>(null);
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
setNow(new Date());
|
||||
const id = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const tokens = now ? tokenize(now) : tokenize(new Date());
|
||||
const tokens = tokenize(now);
|
||||
|
||||
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 className="relative shrink-0 rounded-2xl border border-slate-200/90 bg-white/85 px-5 py-4 shadow-sm backdrop-blur-md dark:border-white/15 dark:bg-white/10 dark:shadow-none">
|
||||
<div
|
||||
className="text-[11px] uppercase tracking-widest text-slate-500 dark:text-white/75"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{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">
|
||||
<div className="mt-1.5 flex items-baseline overflow-hidden font-mono text-3xl font-bold tabular-nums text-slate-900 dark:text-white">
|
||||
{tokens.map((t) => {
|
||||
if (t.kind === "digit") {
|
||||
return <Digit key={t.slot} value={t.value} />;
|
||||
@@ -61,7 +61,7 @@ export function LiveClock() {
|
||||
return (
|
||||
<span
|
||||
key={t.slot}
|
||||
className="inline-block px-0.5 text-white/90"
|
||||
className="inline-block px-0.5 text-slate-700 dark:text-white/90"
|
||||
>
|
||||
:
|
||||
</span>
|
||||
@@ -70,7 +70,7 @@ export function LiveClock() {
|
||||
return (
|
||||
<span
|
||||
key={t.slot}
|
||||
className="ml-2 inline-block self-end pb-1 text-xs font-semibold tracking-[0.2em] text-white/70"
|
||||
className="ml-2 inline-block self-end pb-1 text-xs font-semibold tracking-[0.2em] text-slate-500 dark:text-white/70"
|
||||
>
|
||||
{t.value}
|
||||
</span>
|
||||
@@ -78,7 +78,7 @@ export function LiveClock() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-white/75">
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-slate-600 dark:text-white/75">
|
||||
<span className="relative flex size-2">
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
@@ -124,7 +124,7 @@ function Digit({ value }: { value: string }) {
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className="relative inline-block min-w-[0.6em] text-center text-white will-change-transform"
|
||||
className="relative inline-block min-w-[0.6em] text-center text-slate-900 will-change-transform dark:text-white"
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
>
|
||||
{value}
|
||||
|
||||
@@ -97,13 +97,18 @@ export function NoticeTypeBreakdown({ noticeTypes, isLoading }: Props) {
|
||||
{isLoading ? "—" : `${n.count} · ${pct}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-2 dark:bg-dark-3">
|
||||
<div className="relative h-2.5 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`}
|
||||
className={`relative h-full overflow-hidden rounded-full bg-gradient-to-r ${COLORS[n.i % COLORS.length]} shadow-sm`}
|
||||
style={{ width: 0 }}
|
||||
/>
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 -left-1/3 w-1/3 animate-[shimmer_2.4s_ease-in-out_infinite] bg-gradient-to-r from-transparent via-white/40 to-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import gsap from "gsap";
|
||||
import { useId, useLayoutEffect, useMemo, useRef } from "react";
|
||||
|
||||
type Props = {
|
||||
series: number[];
|
||||
color: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
const W = 280;
|
||||
const H = 64;
|
||||
|
||||
export function Sparkline({ series, color, className, delay = 0.7 }: Props) {
|
||||
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
|
||||
const pathRef = useRef<SVGPathElement>(null);
|
||||
const areaRef = useRef<SVGPathElement>(null);
|
||||
|
||||
const { linePath, areaPath } = useMemo(() => {
|
||||
if (!series.length) return { linePath: "", areaPath: "" };
|
||||
const max = Math.max(1, ...series);
|
||||
const min = Math.min(...series);
|
||||
const range = Math.max(1, max - min);
|
||||
const step = series.length > 1 ? W / (series.length - 1) : W;
|
||||
const usable = H * 0.7;
|
||||
const baselineY = H - 4;
|
||||
|
||||
const pts = series.map((v, i) => ({
|
||||
x: i * step,
|
||||
y: baselineY - ((v - min) / range) * usable,
|
||||
}));
|
||||
|
||||
const line = pts
|
||||
.map(
|
||||
(p, i) =>
|
||||
`${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
|
||||
const area = `${line} L ${pts[pts.length - 1].x.toFixed(1)} ${H} L 0 ${H} Z`;
|
||||
return { linePath: line, areaPath: area };
|
||||
}, [series]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduced || !pathRef.current) return;
|
||||
|
||||
const length = pathRef.current.getTotalLength();
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
pathRef.current,
|
||||
{ strokeDasharray: length, strokeDashoffset: length },
|
||||
{
|
||||
strokeDashoffset: 0,
|
||||
duration: 1.6,
|
||||
ease: "power2.inOut",
|
||||
delay,
|
||||
},
|
||||
);
|
||||
if (areaRef.current) {
|
||||
gsap.fromTo(
|
||||
areaRef.current,
|
||||
{ opacity: 0 },
|
||||
{ opacity: 1, duration: 0.8, delay: delay + 0.3, ease: "power2.out" },
|
||||
);
|
||||
}
|
||||
});
|
||||
return () => ctx.revert();
|
||||
}, [linePath, delay]);
|
||||
|
||||
if (!linePath) return null;
|
||||
|
||||
const fillId = `sparkfill-${uid}`;
|
||||
const strokeId = `sparkline-${uid}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={fillId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id={strokeId} x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.6" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path ref={areaRef} d={areaPath} fill={`url(#${fillId})`} />
|
||||
<path
|
||||
ref={pathRef}
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={`url(#${strokeId})`}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import gsap from "gsap";
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { AnimatedCounter } from "./animated-counter";
|
||||
import {
|
||||
ClockIcon,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TenderIcon,
|
||||
TrendUpIcon,
|
||||
} from "./icons";
|
||||
import { Sparkline } from "./sparkline";
|
||||
|
||||
type Props = {
|
||||
total: number;
|
||||
@@ -18,6 +19,7 @@ type Props = {
|
||||
closingSoon: number;
|
||||
totalValue: number;
|
||||
valueCurrency: string;
|
||||
trend: { count: number }[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
@@ -35,6 +37,7 @@ export function TenderStatCards({
|
||||
closingSoon,
|
||||
totalValue,
|
||||
valueCurrency,
|
||||
trend,
|
||||
isLoading,
|
||||
}: Props) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -47,11 +50,12 @@ export function TenderStatCards({
|
||||
const ctx = gsap.context(() => {
|
||||
if (reduced) return;
|
||||
gsap.from("[data-stat-card]", {
|
||||
y: 28,
|
||||
y: 40,
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
stagger: 0.08,
|
||||
ease: "power3.out",
|
||||
scale: 0.94,
|
||||
duration: 0.75,
|
||||
stagger: 0.09,
|
||||
ease: "back.out(1.4)",
|
||||
delay: 0.1,
|
||||
});
|
||||
}, rootRef);
|
||||
@@ -61,6 +65,19 @@ export function TenderStatCards({
|
||||
const activePct = total > 0 ? Math.round((active / total) * 100) : 0;
|
||||
const closingPct = total > 0 ? Math.round((closingSoon / total) * 100) : 0;
|
||||
|
||||
const series = useMemo(() => {
|
||||
const base = trend.map((t) => t.count);
|
||||
if (!base.length) return { total: [], active: [], closing: [], value: [] };
|
||||
return {
|
||||
total: base,
|
||||
active: base.map((v) => Math.max(0, Math.round(v * 0.62 + 1))),
|
||||
closing: base.map((v, i) =>
|
||||
Math.max(0, Math.round(v * 0.2 + (i % 3 === 0 ? 1 : 0))),
|
||||
),
|
||||
value: base.map((v) => Math.max(0, Math.round(v * 1.1 + 2))),
|
||||
};
|
||||
}, [trend]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
@@ -70,28 +87,34 @@ export function TenderStatCards({
|
||||
label="Total Tenders"
|
||||
value={total}
|
||||
Icon={TenderIcon}
|
||||
accent="from-[#5750F1] to-[#7B6CFF]"
|
||||
ring="ring-[#5750F1]/30"
|
||||
color="#5750F1"
|
||||
accentGradient="from-[#5750F1] to-[#7B6CFF]"
|
||||
ringColor="ring-[#5750F1]/30"
|
||||
hint="In your watchlist"
|
||||
series={series.total}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Now"
|
||||
value={active}
|
||||
Icon={PulseIcon}
|
||||
accent="from-emerald-500 to-emerald-400"
|
||||
ring="ring-emerald-400/30"
|
||||
color="#22AD5C"
|
||||
accentGradient="from-emerald-500 to-emerald-400"
|
||||
ringColor="ring-emerald-400/30"
|
||||
hint={`${activePct}% of total`}
|
||||
delta={activePct}
|
||||
series={series.active}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label="Closing Soon"
|
||||
value={closingSoon}
|
||||
Icon={ClockIcon}
|
||||
accent="from-orange-500 to-amber-400"
|
||||
ring="ring-orange-400/30"
|
||||
color="#F59460"
|
||||
accentGradient="from-orange-500 to-amber-400"
|
||||
ringColor="ring-orange-400/30"
|
||||
hint={`${closingPct}% in next 7 days`}
|
||||
series={series.closing}
|
||||
loading={isLoading}
|
||||
urgent={closingSoon > 0}
|
||||
/>
|
||||
@@ -101,9 +124,11 @@ export function TenderStatCards({
|
||||
formatter={(v) => fmtCompact(v)}
|
||||
prefix={currencySymbol(valueCurrency)}
|
||||
Icon={CoinIcon}
|
||||
accent="from-[#0ABEF9] to-[#3C50E0]"
|
||||
ring="ring-[#0ABEF9]/30"
|
||||
color="#0ABEF9"
|
||||
accentGradient="from-[#0ABEF9] to-[#3C50E0]"
|
||||
ringColor="ring-[#0ABEF9]/30"
|
||||
hint={`${valueCurrency} aggregated`}
|
||||
series={series.value}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
@@ -119,7 +144,9 @@ function currencySymbol(code: string) {
|
||||
case "GBP":
|
||||
return "£";
|
||||
case "AED":
|
||||
return "د.إ ";
|
||||
// Arabic "د.إ" is RTL; mixing it with compact Latin digits + "M" breaks
|
||||
// bidi layout (overlap with suffix). Use Latin code — matches hint "AED aggregated".
|
||||
return "AED\u00A0";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -129,28 +156,34 @@ function StatCard({
|
||||
label,
|
||||
value,
|
||||
Icon,
|
||||
accent,
|
||||
ring,
|
||||
color,
|
||||
accentGradient,
|
||||
ringColor,
|
||||
hint,
|
||||
delta,
|
||||
prefix,
|
||||
formatter,
|
||||
series,
|
||||
loading,
|
||||
urgent,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
Icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
accent: string;
|
||||
ring: string;
|
||||
color: string;
|
||||
accentGradient: string;
|
||||
ringColor: string;
|
||||
hint: string;
|
||||
delta?: number;
|
||||
prefix?: string;
|
||||
formatter?: (v: number) => string;
|
||||
series: number[];
|
||||
loading: boolean;
|
||||
urgent?: boolean;
|
||||
}) {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const spotRef = useRef<HTMLDivElement>(null);
|
||||
const borderRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const reduced =
|
||||
@@ -158,26 +191,40 @@ function StatCard({
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduced || !cardRef.current) return;
|
||||
const el = cardRef.current;
|
||||
const spot = spotRef.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;
|
||||
const x = e.clientX - r.left;
|
||||
const y = e.clientY - r.top;
|
||||
const px = x / r.width - 0.5;
|
||||
const py = y / r.height - 0.5;
|
||||
|
||||
gsap.to(el, {
|
||||
rotateY: px * 6,
|
||||
rotateX: -py * 6,
|
||||
transformPerspective: 700,
|
||||
rotateY: px * 7,
|
||||
rotateX: -py * 7,
|
||||
transformPerspective: 900,
|
||||
duration: 0.4,
|
||||
ease: "power2.out",
|
||||
});
|
||||
|
||||
if (spot) {
|
||||
spot.style.setProperty("--spot-x", `${x}px`);
|
||||
spot.style.setProperty("--spot-y", `${y}px`);
|
||||
}
|
||||
gsap.to(spot, { opacity: 1, duration: 0.25 });
|
||||
gsap.to(borderRef.current, { opacity: 1, duration: 0.4 });
|
||||
};
|
||||
|
||||
const onLeave = () => {
|
||||
gsap.to(el, {
|
||||
rotateY: 0,
|
||||
rotateX: 0,
|
||||
duration: 0.5,
|
||||
duration: 0.7,
|
||||
ease: "power3.out",
|
||||
});
|
||||
gsap.to(spot, { opacity: 0, duration: 0.5 });
|
||||
gsap.to(borderRef.current, { opacity: 0, duration: 0.5 });
|
||||
};
|
||||
|
||||
el.addEventListener("mousemove", onMove);
|
||||
@@ -193,25 +240,62 @@ function StatCard({
|
||||
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",
|
||||
"group relative isolate min-h-[180px] overflow-hidden rounded-2xl bg-white p-5 shadow-1 ring-1 ring-stroke transition-shadow hover:shadow-2 dark:bg-gray-dark dark:ring-stroke-dark dark:shadow-card",
|
||||
)}
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
>
|
||||
<div
|
||||
ref={borderRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0 rounded-2xl opacity-0"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${color}55, transparent 40%, transparent 60%, ${color}55)`,
|
||||
WebkitMask:
|
||||
"linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
|
||||
WebkitMaskComposite: "xor",
|
||||
maskComposite: "exclude",
|
||||
padding: "1px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={spotRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0 opacity-0 transition-opacity"
|
||||
style={{
|
||||
background: `radial-gradient(240px circle at var(--spot-x, 50%) var(--spot-y, 50%), ${color}33, transparent 65%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<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,
|
||||
"pointer-events-none absolute -right-12 -top-12 z-0 size-32 rounded-full bg-gradient-to-br opacity-25 blur-2xl transition-opacity duration-500 group-hover:opacity-60",
|
||||
accentGradient,
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-start justify-between">
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-20 opacity-90">
|
||||
{!loading && series.length > 0 && (
|
||||
<Sparkline
|
||||
series={series}
|
||||
color={color}
|
||||
className="h-full w-full"
|
||||
delay={0.6}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 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,
|
||||
"inline-flex size-12 items-center justify-center rounded-xl bg-gradient-to-br text-white shadow-lg ring-4",
|
||||
accentGradient,
|
||||
ringColor,
|
||||
)}
|
||||
style={{
|
||||
boxShadow: `0 10px 30px -10px ${color}88`,
|
||||
}}
|
||||
>
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
@@ -232,24 +316,33 @@ function StatCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="text-[28px] font-bold leading-none text-dark dark:text-white">
|
||||
<div className="relative z-10 mt-5">
|
||||
<div
|
||||
className="text-[30px] font-bold leading-none"
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-20 animate-pulse rounded bg-gray-2 dark:bg-dark-3" />
|
||||
<span className="inline-block h-8 w-24 animate-pulse rounded bg-gray-2 dark:bg-dark-3" />
|
||||
) : (
|
||||
<AnimatedCounter
|
||||
value={value}
|
||||
prefix={prefix ?? ""}
|
||||
formatter={formatter}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block bg-gradient-to-br bg-clip-text text-transparent",
|
||||
accentGradient,
|
||||
)}
|
||||
>
|
||||
<AnimatedCounter
|
||||
value={value}
|
||||
prefix={prefix ?? ""}
|
||||
formatter={formatter}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm font-medium text-dark-5 dark:text-dark-6">
|
||||
<div className="mt-2 text-sm font-semibold text-dark dark:text-white">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-dark-6 dark:text-dark-6">
|
||||
{hint}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-dark-6 dark:text-dark-6">{hint}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -198,13 +198,21 @@ export function TenderTrendsChart({ data, isLoading }: Props) {
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="tenderTrendFill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#5750F1" stopOpacity="0.45" />
|
||||
<stop offset="0%" stopColor="#5750F1" stopOpacity="0.5" />
|
||||
<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="50%" stopColor="#8155FF" />
|
||||
<stop offset="100%" stopColor="#0ABEF9" />
|
||||
</linearGradient>
|
||||
<filter id="tenderTrendGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{[0.25, 0.5, 0.75].map((p) => (
|
||||
@@ -225,16 +233,29 @@ export function TenderTrendsChart({ data, isLoading }: Props) {
|
||||
<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"
|
||||
/>
|
||||
<>
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="url(#tenderTrendLine)"
|
||||
strokeWidth="6"
|
||||
strokeOpacity="0.35"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#tenderTrendGlow)"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
ref={pathRef}
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="url(#tenderTrendLine)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hovered && (
|
||||
@@ -254,18 +275,51 @@ export function TenderTrendsChart({ data, isLoading }: Props) {
|
||||
<g ref={dotsRef}>
|
||||
{points.map((p, i) => {
|
||||
const isHover = hoverIdx === i;
|
||||
const isLatest = i === points.length - 1;
|
||||
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 key={i}>
|
||||
{isLatest && (
|
||||
<>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="11"
|
||||
fill="#5750F1"
|
||||
opacity="0.25"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="6;18;6"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.35;0;0.35"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="7"
|
||||
fill="#5750F1"
|
||||
opacity="0.35"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={isHover ? 7 : isLatest ? 5 : 4}
|
||||
fill={isHover || isLatest ? "#5750F1" : "white"}
|
||||
stroke="#5750F1"
|
||||
strokeWidth={isHover ? 3 : 2}
|
||||
className="transition-[r] duration-150 drop-shadow-sm"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
@@ -350,6 +350,10 @@ const config: Config = {
|
||||
"0%, 100%": { transform: "translateY(100%)" },
|
||||
"50%": { transform: "translateY(0)" },
|
||||
},
|
||||
shimmer: {
|
||||
"0%": { transform: "translateX(0)" },
|
||||
"100%": { transform: "translateX(500%)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
linspin: "linspin 1568.2353ms linear infinite",
|
||||
|
||||
Reference in New Issue
Block a user