diff --git a/.env.production b/.env.production index a2683c1..9d6c8a2 100644 --- a/.env.production +++ b/.env.production @@ -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 diff --git a/src/app/(home)/_components/dashboard/hero.tsx b/src/app/(home)/_components/dashboard/hero.tsx index cb6adac..7982a48 100644 --- a/src/app/(home)/_components/dashboard/hero.tsx +++ b/src/app/(home)/_components/dashboard/hero.tsx @@ -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(null); + const auroraRef = useRef(null); const orbARef = useRef(null); const orbBRef = useRef(null); + const orbCRef = useRef(null); const titleRef = useRef(null); + const shineRef = useRef(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("[data-title-inner]"); + if (inner) { + gsap.killTweensOf(inner); + gsap.set(inner, { clearProps: "opacity,transform" }); + } + } + root?.querySelectorAll("[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("[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( + "[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("[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("[data-title-inner]"); + if (titleInner) gsap.killTweensOf(titleInner); + if (root) gsap.killTweensOf(root); + root + ?.querySelectorAll("[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 (
+
+
+
+ +
-
+
+ +
- + + + + Live tender intelligence +

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

-

+

{sub}

-
+
@@ -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 = { + 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 (
- + {label} @@ -207,6 +392,10 @@ function HeroPill({ )} +
); } diff --git a/src/app/(home)/_components/dashboard/index.tsx b/src/app/(home)/_components/dashboard/index.tsx index 8d5c817..a9a4a41 100644 --- a/src/app/(home)/_components/dashboard/index.tsx +++ b/src/app/(home)/_components/dashboard/index.tsx @@ -40,6 +40,7 @@ export function TenderDashboard() { closingSoon={closingSoon} totalValue={totalValue} valueCurrency={valueCurrency} + trend={trend} isLoading={isPending} /> diff --git a/src/app/(home)/_components/dashboard/live-clock.tsx b/src/app/(home)/_components/dashboard/live-clock.tsx index 4446f1d..2f8b219 100644 --- a/src/app/(home)/_components/dashboard/live-clock.tsx +++ b/src/app/(home)/_components/dashboard/live-clock.tsx @@ -30,29 +30,29 @@ function tokenize(date: Date): Token[] { } export function LiveClock() { - const [now, setNow] = useState(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 ( -
-
- {now - ? now.toLocaleDateString("en-US", { - weekday: "long", - month: "short", - day: "numeric", - }) - : "—"} +
+
+ {now.toLocaleDateString("en-US", { + weekday: "long", + month: "short", + day: "numeric", + })}
-
+
{tokens.map((t) => { if (t.kind === "digit") { return ; @@ -61,7 +61,7 @@ export function LiveClock() { return ( : @@ -70,7 +70,7 @@ export function LiveClock() { return ( {t.value} @@ -78,7 +78,7 @@ export function LiveClock() { })}
-
+
@@ -124,7 +124,7 @@ function Digit({ value }: { value: string }) { return ( {value} diff --git a/src/app/(home)/_components/dashboard/notice-types.tsx b/src/app/(home)/_components/dashboard/notice-types.tsx index 5da9025..3b7ff2c 100644 --- a/src/app/(home)/_components/dashboard/notice-types.tsx +++ b/src/app/(home)/_components/dashboard/notice-types.tsx @@ -97,13 +97,18 @@ export function NoticeTypeBreakdown({ noticeTypes, isLoading }: Props) { {isLoading ? "—" : `${n.count} · ${pct}%`}
-
+
+ > + +
); diff --git a/src/app/(home)/_components/dashboard/sparkline.tsx b/src/app/(home)/_components/dashboard/sparkline.tsx new file mode 100644 index 0000000..179ad4b --- /dev/null +++ b/src/app/(home)/_components/dashboard/sparkline.tsx @@ -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(null); + const areaRef = useRef(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 ( + + + + + + + + + + + + + + + ); +} diff --git a/src/app/(home)/_components/dashboard/stat-cards.tsx b/src/app/(home)/_components/dashboard/stat-cards.tsx index d481ed4..79dcdd3 100644 --- a/src/app/(home)/_components/dashboard/stat-cards.tsx +++ b/src/app/(home)/_components/dashboard/stat-cards.tsx @@ -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(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 (
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} />
@@ -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) => 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(null); + const spotRef = useRef(null); + const borderRef = useRef(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" }} > +
+ +
+
-
+ +
+ {!loading && series.length > 0 && ( + + )} +
+ +
@@ -232,24 +316,33 @@ function StatCard({ )}
-
-
+
+
{loading ? ( - + ) : ( - + + + )}
-
+
{label}
-
- {hint} -
+
{hint}
); diff --git a/src/app/(home)/_components/dashboard/trends-chart.tsx b/src/app/(home)/_components/dashboard/trends-chart.tsx index 185912f..a537e50 100644 --- a/src/app/(home)/_components/dashboard/trends-chart.tsx +++ b/src/app/(home)/_components/dashboard/trends-chart.tsx @@ -198,13 +198,21 @@ export function TenderTrendsChart({ data, isLoading }: Props) { > - + + + + + + + + + {[0.25, 0.5, 0.75].map((p) => ( @@ -225,16 +233,29 @@ export function TenderTrendsChart({ data, isLoading }: Props) { )} {linePath && ( - + <> + + + )} {hovered && ( @@ -254,18 +275,51 @@ export function TenderTrendsChart({ data, isLoading }: Props) { {points.map((p, i) => { const isHover = hoverIdx === i; + const isLatest = i === points.length - 1; return ( - + + {isLatest && ( + <> + + + + + + + )} + + ); })} diff --git a/tailwind.config.ts b/tailwind.config.ts index dce9b97..9017b0a 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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",