diff --git a/dashboard/app/dashboard/billing/page.tsx b/dashboard/app/dashboard/billing/page.tsx new file mode 100644 index 0000000..5ef654b --- /dev/null +++ b/dashboard/app/dashboard/billing/page.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { + CreditCard, + Check, + ExternalLink, + Download, + ChevronRight, +} from "lucide-react"; +import { api } from "@/lib/api"; +import { stubBillingUsage } from "@/lib/stubs"; +import type { BillingUsage, Invoice, UsageBar } from "@/lib/stubs"; +import { usagePercent, usageStatus, STATUS_STYLES } from "@/lib/usage"; + +const INVOICE_STATUS_STYLES: Record = { + paid: "bg-chart-3/10 text-chart-3", + open: "bg-muted text-chart-2", + void: "bg-destructive/10 text-destructive", +}; + +export default function BillingPage() { + const [billing, setBilling] = useState(null); + const [loading, setLoading] = useState(true); + const [portalLoading, setPortalLoading] = useState(false); + + useEffect(() => { + api + .get("/billing/usage") + .then((data) => setBilling(data ?? stubBillingUsage)) + .catch(() => setBilling(stubBillingUsage)) + .finally(() => setLoading(false)); + }, []); + + const openPortal = async () => { + setPortalLoading(true); + try { + const res = await api.post<{ url: string }>("/billing/portal", {}); + if (res?.url) window.location.href = res.url; + } finally { + setPortalLoading(false); + } + }; + + if (loading || !billing) { + return ( +
+
+
+ ); + } + + const { plan, usage, invoices } = billing; + + return ( +
+
+

Billing

+

+ Manage your plan, payment method and invoices +

+
+ +
+ {/* Current plan */} +
+
+
+

+ Current plan +

+

+ {plan.name} +

+

+ {plan.price} · renews {plan.renews_at} +

+
+ + Active + +
+
+ + +
+

+ Opens the secure Stripe billing portal to update payment, download + invoices, or cancel. +

+
+ + {/* Payment method */} +
+

+ Payment method +

+ {plan.card ? ( +
+
+ +
+

+ {plan.card.brand} •••• {plan.card.last4} +

+

+ Expires {plan.card.expires} +

+
+
+ + Valid + +
+ ) : ( +
+ No payment method on file. +
+ )} +
+ Billing email + + {plan.billing_email} + +
+
+ Next invoice + + {plan.next_invoice.amount} on {plan.next_invoice.date} + +
+
+
+ + {/* Plan usage this cycle */} +
+
+

+ Plan usage this cycle +

+ + View details + +
+
+ {usage.map((bar) => ( + + ))} +
+
+ + {/* Recent invoices */} +
+
+

+ Recent invoices +

+
+ + + + + + + + + + {invoices.map((inv) => ( + + + + + + + ))} + +
DateAmountStatus +
{inv.date}{inv.amount} + + {inv.status} + + + +
+
+
+ ); +} + +function UsageSummaryRow({ bar }: { bar: UsageBar }) { + const status = usageStatus(bar); + const pct = usagePercent(bar); + const styles = STATUS_STYLES[status]; + + return ( +
+
+ {bar.label} + + {bar.used}/{bar.cap} + +
+
+
+
+
+ ); +} diff --git a/dashboard/app/dashboard/locations/page.tsx b/dashboard/app/dashboard/locations/page.tsx new file mode 100644 index 0000000..2497a2f --- /dev/null +++ b/dashboard/app/dashboard/locations/page.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useState } from "react"; +import { + Building2, + Store, + Star, + MapPin, + Plus, + Check, + ChevronDown, + Calendar, + Stethoscope, + Globe, +} from "lucide-react"; +import DemoBadge from "@/components/DemoBadge"; +import Toggle from "@/components/Toggle"; +import { stubLocations } from "@/lib/stubs"; +import type { Location } from "@/lib/stubs"; +import { locationStats } from "@/lib/stats"; + +const targetIcons: Record = { + gbp: Building2, + healthengine: Calendar, + doctify: Stethoscope, + website: Globe, +}; + +const statusBadge: Record = { + synced: "bg-chart-3/10 text-chart-3", + setup_needed: "bg-muted text-chart-2", +}; + +const statusLabel: Record = { + synced: "Synced", + setup_needed: "Setup needed", +}; + +function venueName(loc: Location): string { + const parts = loc.name.split(" — "); + return parts.length > 1 ? parts[1] : loc.name; +} + +export default function LocationsPage() { + const [selectedId, setSelectedId] = useState(stubLocations[0].id); + const [targets, setTargets] = useState>( + Object.fromEntries( + stubLocations[0].targets.map((t) => [t.key, t.enabled]), + ), + ); + + const selected = stubLocations.find((l) => l.id === selectedId)!; + const stats = locationStats(stubLocations); + + const handleSelect = (id: string) => { + const loc = stubLocations.find((l) => l.id === id)!; + setSelectedId(id); + setTargets( + Object.fromEntries(loc.targets.map((t) => [t.key, t.enabled])), + ); + }; + + const statCards = [ + { label: "Locations", value: stats.total.toString(), icon: Building2 }, + { + label: "Menus synced", + value: `${stats.synced}/${stats.total}`, + icon: Store, + }, + { label: "Reviews this week", value: "23", icon: Star }, + { label: "Avg local rank", value: "4.2", icon: MapPin }, + ]; + + const columns = [selected.targets.slice(0, 2), selected.targets.slice(2, 4)]; + + return ( +
+
+
+

Locations

+

+ Manage venues and per-location menu-sync targets +

+
+
+ +
+ + Bondi Dental + + · {stats.total} venues + + +
+ +
+
+ +
+ {statCards.map((stat) => { + const Icon = stat.icon; + return ( +
+
+ +
+

+ {stat.value} +

+

+ {stat.label} +

+
+
+
+ ); + })} +
+ +
+ + + + + + + + + + + + {stubLocations.map((loc) => ( + + + + + + + + ))} + +
LocationMenu-sync targetStatusLast syncActions
+

{loc.name}

+

{loc.area}

+
+ {loc.menuSyncTarget === "Not configured" ? ( + Not configured + ) : ( + loc.menuSyncTarget + )} + + + {statusLabel[loc.status]} + + + {loc.lastSync} + + +
+
+ +
+
+

+ Menu-sync targets — {venueName(selected)} +

+ {selected.status === "synced" && ( + + Synced {selected.lastSync} + + )} +
+
+ {columns.map((col, colIdx) => ( +
+ {col.map((target, idx) => { + const Icon = targetIcons[target.key] ?? Store; + return ( +
+
+ +
+

+ {target.label} +

+

+ {target.subtitle} +

+
+
+ + setTargets({ ...targets, [target.key]: v }) + } + aria-label={`Toggle ${target.label}`} + /> +
+ ); + })} +
+ ))} +
+
+ +
+
+
+ ); +} diff --git a/dashboard/app/dashboard/rebook/page.tsx b/dashboard/app/dashboard/rebook/page.tsx new file mode 100644 index 0000000..f406f8c --- /dev/null +++ b/dashboard/app/dashboard/rebook/page.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useState } from "react"; +import { + Users, + Clock, + MessageSquare, + RefreshCw, + TrendingUp, + Building2, + ChevronDown, +} from "lucide-react"; +import DemoBadge from "@/components/DemoBadge"; +import Toggle from "@/components/Toggle"; +import { stubPractitioners } from "@/lib/stubs"; +import type { Practitioner, LapsedPatient } from "@/lib/stubs"; +import { rebookStats, conversionRate } from "@/lib/stats"; + +const patientStatusBadge: Record< + LapsedPatient["status"], + { label: string; className: string } +> = { + rebooked: { + label: "Rebooked", + className: "bg-chart-3/10 text-chart-3", + }, + sent: { label: "Sent", className: "bg-chart-4/10 text-chart-4" }, + queued: { label: "Queued", className: "bg-muted text-chart-2" }, + opted_out: { + label: "Opted out", + className: "bg-muted text-muted-foreground", + }, +}; + +const statChipColors = [ + "bg-accent/10 text-accent", + "bg-muted text-chart-2", + "bg-chart-4/10 text-chart-4", + "bg-chart-3/10 text-chart-3", +]; + +export default function RebookPage() { + const [autoFollowUp, setAutoFollowUp] = useState< + Record + >( + Object.fromEntries( + stubPractitioners.map((p) => [p.id, p.autoFollowUp]), + ), + ); + const [selectedId, setSelectedId] = useState(stubPractitioners[0].id); + + const stats = rebookStats(stubPractitioners); + const selected = stubPractitioners.find((p) => p.id === selectedId)!; + const selectedRate = conversionRate(selected.sent, selected.rebooked); + + const statCards = [ + { + label: "Practitioners", + value: stats.practitioners.toString(), + icon: Users, + }, + { + label: "Lapsed patients", + value: stats.lapsed.toString(), + icon: Clock, + }, + { + label: "Follow-ups sent", + value: stats.sent.toString(), + icon: MessageSquare, + }, + { + label: "Rebooked", + value: stats.rebooked.toString(), + icon: RefreshCw, + }, + ]; + + return ( +
+
+
+

Rebook

+

+ Lapsed-patient follow-up by practitioner +

+
+
+ +
+ + Bondi Dental Clinic + +
+
+
+ +
+ {statCards.map((stat, i) => { + const Icon = stat.icon; + return ( +
+
+ + + +
+

+ {stat.value} +

+

+ {stat.label} +

+
+
+
+ ); + })} +
+ +
+ + + + + + + + + + + + {stubPractitioners.map((p) => { + const rate = conversionRate(p.sent, p.rebooked); + const isOn = autoFollowUp[p.id]; + return ( + + + + + + + + ); + })} + +
PractitionerLapsed patientsFollow-ups sentRebookedAuto follow-up
+ + {p.lapsed}{p.sent} + + + + {p.rebooked} + + + ({rate}%) + + + +
+ + setAutoFollowUp({ ...autoFollowUp, [p.id]: v }) + } + aria-label={`Auto follow-up for ${p.name}`} + /> + + {isOn ? "Follow-ups on" : "Opted out"} + +
+
+
+ + + setAutoFollowUp({ ...autoFollowUp, [selected.id]: v }) + } + conversionRate={selectedRate} + /> +
+ ); +} + +function PractitionerDetail({ + practitioner, + autoFollowUp, + onToggleFollowUp, + conversionRate: rate, +}: { + practitioner: Practitioner; + autoFollowUp: boolean; + onToggleFollowUp: (v: boolean) => void; + conversionRate: number; +}) { + const detailStats = [ + { + label: "Messages sent", + value: practitioner.sent.toString(), + className: "text-chart-2", + }, + { + label: "Rebooked", + value: practitioner.rebooked.toString(), + className: "text-chart-3", + }, + { + label: "Conversion", + value: `${rate}%`, + className: "text-foreground", + }, + ]; + + return ( +
+
+
+

+ {practitioner.name} +

+

+ {practitioner.specialty} · {practitioner.lapsed} lapsed patients + (>6 months) +

+
+
+ Auto follow-up + +
+
+ +
+ {detailStats.map((stat) => ( +
+

+ {stat.value} +

+

+ {stat.label} +

+
+ ))} +
+ +
+ + + + + + + + + + + {practitioner.patients.map((patient) => { + const badge = patientStatusBadge[patient.status]; + return ( + + + + + + + ); + })} + +
PatientLast visitChannelStatus
+ {patient.name} + + {patient.lastVisit} + + {patient.channel} + + + {badge.label} + +
+
+
+ ); +} diff --git a/dashboard/app/dashboard/reports/page.tsx b/dashboard/app/dashboard/reports/page.tsx index a2cebb3..18c5063 100644 --- a/dashboard/app/dashboard/reports/page.tsx +++ b/dashboard/app/dashboard/reports/page.tsx @@ -1,10 +1,22 @@ "use client"; import { useState } from "react"; -import { RefreshCw, Mail } from "lucide-react"; -import RankingChart from "@/components/RankingChart"; +import { + RefreshCw, + Mail, + Search, + MapPin, + TrendingUp, + DollarSign, + BookOpen, + Clock, + ChevronRight, +} from "lucide-react"; +import DualRankingTable from "@/components/DualRankingTable"; import DemoBadge from "@/components/DemoBadge"; -import { stubRankings, stubCompetitorBriefs } from "@/lib/stubs"; +import { stubDualRankings, stubCompetitorChanges } from "@/lib/stubs"; +import type { StructuredDiff } from "@/lib/stubs"; +import { dualRankingStats } from "@/lib/stats"; const threatColors: Record = { low: "bg-chart-3/10 text-chart-3", @@ -12,9 +24,73 @@ const threatColors: Record = { high: "bg-destructive/10 text-destructive", }; +const diffTypeMeta: Record< + StructuredDiff["type"], + { icon: typeof DollarSign; chipBg: string; pillBg: string } +> = { + price: { + icon: DollarSign, + chipBg: "bg-destructive/10 text-destructive", + pillBg: "bg-destructive/10 text-destructive", + }, + menu: { + icon: BookOpen, + chipBg: "bg-chart-4/10 text-chart-4", + pillBg: "bg-chart-4/10 text-chart-4", + }, + hours: { + icon: Clock, + chipBg: "bg-chart-3/10 text-chart-3", + pillBg: "bg-chart-3/10 text-chart-3", + }, +}; + +const legendItems: { + type: StructuredDiff["type"]; + label: string; + pillClass: string; +}[] = [ + { type: "price", label: "Price change", pillClass: "bg-destructive/10 text-destructive" }, + { type: "menu", label: "Menu / service change", pillClass: "bg-chart-4/10 text-chart-4" }, + { type: "hours", label: "Hours change", pillClass: "bg-chart-3/10 text-chart-3" }, +]; + export default function ReportsPage() { const [tab, setTab] = useState<"seo" | "competitors">("seo"); + const rankingStats = dualRankingStats(stubDualRankings); + const totalChanges = stubCompetitorChanges.reduce( + (s, c) => s + c.changes.length, + 0, + ); + + const seoStats = [ + { + label: "Avg organic rank", + value: rankingStats.avgOrganic.toFixed(1), + icon: Search, + chipClass: "bg-muted text-muted-foreground", + }, + { + label: "Avg Local Pack rank", + value: rankingStats.avgLocalPack.toFixed(1), + icon: MapPin, + chipClass: "bg-chart-4/10 text-chart-4", + }, + { + label: "Keywords improving", + value: rankingStats.improving.toString(), + icon: TrendingUp, + chipClass: "bg-chart-3/10 text-chart-3", + }, + { + label: "In top 3 (Maps)", + value: rankingStats.topThreeMaps.toString(), + icon: MapPin, + chipClass: "bg-accent/10 text-accent", + }, + ]; + return (
@@ -59,36 +135,144 @@ export default function ReportsPage() {
{tab === "seo" ? ( -
-

- Keyword Rankings -

- -
+ <> +
+ {seoStats.map((stat) => { + const Icon = stat.icon; + return ( +
+
+ + + +
+

+ {stat.value} +

+

+ {stat.label} +

+
+
+
+ ); + })} +
+ +
+
+

+ Keyword Rankings +

+ + Updated 21 Jul 2026, 6:00 AM + +
+ +
+ ) : ( -
- {stubCompetitorBriefs.map((brief) => ( -
-
+ <> +
+
+ {legendItems.map((item) => { + const Icon = diffTypeMeta[item.type].icon; + return ( + + + + {item.label} + + + ); + })} + + {totalChanges} structured changes across{" "} + {stubCompetitorChanges.length} competitors this week + +
+
+ +
+ {stubCompetitorChanges.map((competitor) => ( +
+
+
+
+

+ {competitor.name} +

+

+ {competitor.domain} +

+
+ + {competitor.threat.toUpperCase()} + +
+
-

{brief.name}

-

{brief.domain}

+ {competitor.changes.map((change, idx) => { + const meta = diffTypeMeta[change.type]; + const Icon = meta.icon; + return ( +
+
+ + + +
+

+ {change.description} +

+
+ + {change.oldValue} + + + + {change.newValue} + +
+
+
+ + {change.timestamp} + +
+ ); + })}
- - {brief.threat.toUpperCase()} -
-

{brief.note}

-
- ))} -
+ ))} +
+ )}
); diff --git a/dashboard/app/dashboard/usage/page.tsx b/dashboard/app/dashboard/usage/page.tsx new file mode 100644 index 0000000..95c4f58 --- /dev/null +++ b/dashboard/app/dashboard/usage/page.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { + Gauge, + ShieldCheck, + FileText, + Search, + MessageSquare, + TrendingUp, + ChevronRight, + type LucideIcon, +} from "lucide-react"; +import { api } from "@/lib/api"; +import { stubBillingUsage } from "@/lib/stubs"; +import type { BillingUsage, UsageBar } from "@/lib/stubs"; +import { + usagePercent, + usageStatus, + usageRemaining, + STATUS_STYLES, +} from "@/lib/usage"; + +/** Per-job icon shown next to each usage bar. */ +const JOB_ICONS: Record = { + "Review drafts": ShieldCheck, + "SEO reports": FileText, + "Competitor briefs": Search, + "Follow-up messages": MessageSquare, +}; + +export default function UsagePage() { + const [billing, setBilling] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api + .get("/billing/usage") + .then((data) => setBilling(data ?? stubBillingUsage)) + .catch(() => setBilling(stubBillingUsage)) + .finally(() => setLoading(false)); + }, []); + + if (loading || !billing) { + return ( +
+
+
+ ); + } + + const { plan, usage } = billing; + + return ( +
+
+
+

Usage & caps

+

+ Track how much of each automation you’ve used this cycle +

+
+ + Manage billing + +
+ + {/* Plan card */} +
+
+ +
+

+ {plan.name} plan +

+

+ Billing cycle resets {plan.renews_at} · {plan.days_left}{" "} + days left +

+
+
+ + Active + +
+ + {/* This cycle */} +
+
+

This cycle

+

+ Used / cap / remaining per job +

+
+
+ {usage.map((bar) => ( + + ))} +
+
+ + {/* Need higher caps */} +
+
+
+ + + Need higher caps? + +
+ + Compare plans + +
+
+
+ ); +} + +function UsageRow({ bar }: { bar: UsageBar }) { + const status = usageStatus(bar); + const pct = usagePercent(bar); + const remaining = usageRemaining(bar); + const styles = STATUS_STYLES[status]; + const Icon = JOB_ICONS[bar.label]; + + return ( +
+
+
+ {Icon && } + + {bar.label} + +
+
+ + {styles.label} + + + {bar.used} + /{bar.cap} + +
+
+
+
+
+

+ {remaining} of quota remaining this cycle +

+
+ ); +} diff --git a/dashboard/components/DualRankingTable.tsx b/dashboard/components/DualRankingTable.tsx new file mode 100644 index 0000000..4ccdc5e --- /dev/null +++ b/dashboard/components/DualRankingTable.tsx @@ -0,0 +1,105 @@ +import { ArrowUp, ArrowDown, Minus, MapPin, Search } from "lucide-react"; +import type { DualRanking } from "@/lib/stubs"; +import { rankDelta } from "@/lib/stats"; + +interface DualRankingTableProps { + data: DualRanking[]; +} + +function DeltaCell({ delta }: { delta: number }) { + const { direction, magnitude } = rankDelta(delta); + if (direction === "none") { + return ( + + No change + + ); + } + if (direction === "up") { + return ( + + Up {magnitude} + + ); + } + return ( + + Down {magnitude} + + ); +} + +export default function DualRankingTable({ data }: DualRankingTableProps) { + return ( +
+
+ + + + #n + + Organic web rank + + + + + #n + + Google Maps Local Pack rank + +
+ +
+ + + + + + + + + + + + + + + + {data.map((row) => ( + + + + + + + + ))} + +
+ Keyword + + Organic + + Google Maps Local Pack +
This weekChangeThis weekChange
+ {row.keyword} + + + #{row.organicThisWeek} + + + + + + #{row.localPackThisWeek} + + + +
+
+
+ ); +} diff --git a/dashboard/components/Sidebar.tsx b/dashboard/components/Sidebar.tsx index 6db5c64..6e892eb 100644 --- a/dashboard/components/Sidebar.tsx +++ b/dashboard/components/Sidebar.tsx @@ -6,6 +6,10 @@ import { LayoutDashboard, BarChart3, Users, + Map, + RefreshCw, + Gauge, + CreditCard, Settings, } from "lucide-react"; import clsx from "clsx"; @@ -14,6 +18,10 @@ const navItems = [ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/dashboard/reports", label: "Reports", icon: BarChart3 }, { href: "/dashboard/clients", label: "Clients", icon: Users }, + { href: "/dashboard/locations", label: "Locations", icon: Map }, + { href: "/dashboard/rebook", label: "Rebook", icon: RefreshCw }, + { href: "/dashboard/usage", label: "Usage", icon: Gauge }, + { href: "/dashboard/billing", label: "Billing", icon: CreditCard }, { href: "/dashboard/settings", label: "Settings", icon: Settings }, ]; diff --git a/dashboard/components/Toggle.tsx b/dashboard/components/Toggle.tsx new file mode 100644 index 0000000..ad31949 --- /dev/null +++ b/dashboard/components/Toggle.tsx @@ -0,0 +1,29 @@ +interface ToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; + "aria-label"?: string; +} + +/** + * Accessible on/off toggle switch matching the app's accent design tokens. + */ +export default function Toggle({ checked, onChange, ...rest }: ToggleProps) { + return ( + + ); +} diff --git a/dashboard/components/TrialBanner.tsx b/dashboard/components/TrialBanner.tsx index d2d9863..d2d31fc 100644 --- a/dashboard/components/TrialBanner.tsx +++ b/dashboard/components/TrialBanner.tsx @@ -1,8 +1,14 @@ "use client"; -import { Bell, CreditCard, AlertTriangle } from "lucide-react"; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Bell, CreditCard, AlertTriangle, Check } from "lucide-react"; import { differenceInDays, parseISO } from "date-fns"; import { getTrialState } from "@/lib/trial-state"; +import { api } from "@/lib/api"; +import { stubBillingUsage } from "@/lib/stubs"; +import type { BillingUsage, PlanInfo } from "@/lib/stubs"; +import { getCap, REVIEW_DRAFTS } from "@/lib/usage"; import DemoBadge from "./DemoBadge"; interface TrialBannerProps { @@ -25,6 +31,9 @@ const STUB_CLIENT = { trial_usage: { review_drafts: 3 }, }; +/** Fallback cap for the review-drafts quota when usage data is unavailable. */ +const DEFAULT_REVIEW_CAP = 100; + const bannerStyles: Record = { active: "bg-accent/10 border-accent/20 text-accent", expiring_soon: "bg-muted border-chart-2/20 text-chart-2", @@ -39,13 +48,62 @@ const icons: Record = { expired: , }; +/** Green banner shown for active paid plans (plan name + Manage billing). */ +function UsageBanner({ plan }: { plan: PlanInfo }) { + return ( +
+
+
+ + + {plan.name} plan · renews {plan.renews_at} + {plan.card && ` · ${plan.card.brand} •••• ${plan.card.last4}`} + +
+
+ + Manage billing + + +
+
+
+ ); +} + export default function TrialBanner({ client: _client }: TrialBannerProps) { const client = _client ?? STUB_CLIENT; + // Seed with the stub so the banner renders immediately (no flash) and falls + // back to it when the live billing endpoint is unavailable. + const [billing, setBilling] = useState(stubBillingUsage); + + useEffect(() => { + let mounted = true; + api.get("/billing/usage").then((data) => { + if (mounted && data) setBilling(data); + }); + return () => { + mounted = false; + }; + }, []); + + // Active paid plan → green usage banner. + if (billing.plan.status === "active") { + return ; + } + const state = getTrialState(client); const daysLeft = differenceInDays( parseISO(client.trial_ends_at), new Date() ); + // Live review-drafts cap from the billing endpoint (replaces the old + // hardcoded /100), falling back to the trial default. + const reviewDraftsCap = + getCap(billing.usage, REVIEW_DRAFTS) ?? DEFAULT_REVIEW_CAP; return (
{state === "active" && - `Trial: ${daysLeft} days remaining | ${client.trial_usage.review_drafts}/100 review drafts used`} + `Trial: ${daysLeft} days remaining | ${client.trial_usage.review_drafts}/${reviewDraftsCap} review drafts used`} {state === "expiring_soon" && `Trial ends in ${daysLeft} days — add card to keep your data`} {state === "card_required" && diff --git a/dashboard/lib/stats.test.ts b/dashboard/lib/stats.test.ts new file mode 100644 index 0000000..3b85600 --- /dev/null +++ b/dashboard/lib/stats.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import { + rebookStats, + conversionRate, + dualRankingStats, + locationStats, + rankDelta, +} from "@/lib/stats"; +import { + stubPractitioners, + stubDualRankings, + stubLocations, +} from "@/lib/stubs"; + +describe("rebookStats", () => { + it("aggregates practitioner metrics from stub data", () => { + const stats = rebookStats(stubPractitioners); + expect(stats.practitioners).toBe(4); + expect(stats.lapsed).toBe(86); + expect(stats.sent).toBe(52); + expect(stats.rebooked).toBe(19); + }); + + it("returns zeros for an empty list", () => { + expect(rebookStats([])).toEqual({ + practitioners: 0, + lapsed: 0, + sent: 0, + rebooked: 0, + }); + }); + + it("handles a single practitioner", () => { + const stats = rebookStats([stubPractitioners[0]]); + expect(stats).toEqual({ + practitioners: 1, + lapsed: 24, + sent: 15, + rebooked: 6, + }); + }); +}); + +describe("conversionRate", () => { + it("computes whole-number percentage", () => { + expect(conversionRate(15, 6)).toBe(40); + expect(conversionRate(10, 3)).toBe(30); + expect(conversionRate(22, 9)).toBe(41); + }); + + it("returns 0 when no follow-ups sent", () => { + expect(conversionRate(0, 5)).toBe(0); + }); + + it("returns 0 for negative sent", () => { + expect(conversionRate(-1, 3)).toBe(0); + }); + + it("rounds to nearest whole number", () => { + expect(conversionRate(3, 1)).toBe(33); + expect(conversionRate(7, 2)).toBe(29); + }); + + it("handles 100% conversion", () => { + expect(conversionRate(5, 5)).toBe(100); + }); +}); + +describe("dualRankingStats", () => { + it("computes averages and counts from stub data", () => { + const stats = dualRankingStats(stubDualRankings); + // organic: (2+11+5+7+6)/5 = 6.2 + expect(stats.avgOrganic).toBe(6.2); + // local pack: (1+6+2+4+7)/5 = 4.0 + expect(stats.avgLocalPack).toBe(4.0); + // organic improving: dentist Bondi(+1), dental implant(+5), invisalign(+3) = 3 + expect(stats.improving).toBe(3); + // local pack top 3: #1, #2 = 2 + expect(stats.topThreeMaps).toBe(2); + }); + + it("returns zeros for empty input", () => { + expect(dualRankingStats([])).toEqual({ + avgOrganic: 0, + avgLocalPack: 0, + improving: 0, + topThreeMaps: 0, + }); + }); + + it("rounds averages to one decimal place", () => { + const stats = dualRankingStats([ + { keyword: "a", organicThisWeek: 1, organicDelta: 1, localPackThisWeek: 2, localPackDelta: 0 }, + { keyword: "b", organicThisWeek: 2, organicDelta: 0, localPackThisWeek: 3, localPackDelta: 1 }, + { keyword: "c", organicThisWeek: 3, organicDelta: -1, localPackThisWeek: 4, localPackDelta: -1 }, + ]); + // organic: (1+2+3)/3 = 2.0 + expect(stats.avgOrganic).toBe(2.0); + // local pack: (2+3+4)/3 = 3.0 + expect(stats.avgLocalPack).toBe(3.0); + expect(stats.improving).toBe(1); + expect(stats.topThreeMaps).toBe(2); // #2 and #3 are <= 3, #4 is not + }); +}); + +describe("locationStats", () => { + it("counts total and synced locations from stub data", () => { + const stats = locationStats(stubLocations); + expect(stats.total).toBe(6); + expect(stats.synced).toBe(5); + }); + + it("returns zeros for empty input", () => { + expect(locationStats([])).toEqual({ total: 0, synced: 0 }); + }); +}); + +describe("rankDelta", () => { + it("classifies positive delta as up", () => { + expect(rankDelta(1)).toEqual({ direction: "up", magnitude: 1 }); + expect(rankDelta(5)).toEqual({ direction: "up", magnitude: 5 }); + }); + + it("classifies negative delta as down with positive magnitude", () => { + expect(rankDelta(-3)).toEqual({ direction: "down", magnitude: 3 }); + expect(rankDelta(-1)).toEqual({ direction: "down", magnitude: 1 }); + }); + + it("classifies zero delta as none", () => { + expect(rankDelta(0)).toEqual({ direction: "none", magnitude: 0 }); + }); +}); diff --git a/dashboard/lib/stats.ts b/dashboard/lib/stats.ts new file mode 100644 index 0000000..33424c9 --- /dev/null +++ b/dashboard/lib/stats.ts @@ -0,0 +1,100 @@ +import type { + Practitioner, + DualRanking, + Location, +} from "@/lib/stubs"; + +export interface RebookStats { + practitioners: number; + lapsed: number; + sent: number; + rebooked: number; +} + +/** + * Aggregate rebook metrics across all practitioners. + */ +export function rebookStats(practitioners: Practitioner[]): RebookStats { + return practitioners.reduce( + (acc, p) => ({ + practitioners: acc.practitioners + 1, + lapsed: acc.lapsed + p.lapsed, + sent: acc.sent + p.sent, + rebooked: acc.rebooked + p.rebooked, + }), + { practitioners: 0, lapsed: 0, sent: 0, rebooked: 0 }, + ); +} + +/** + * Conversion rate as a whole-number percentage (rebooked / sent). + * Returns 0 when no follow-ups have been sent. + */ +export function conversionRate(sent: number, rebooked: number): number { + if (sent <= 0) return 0; + return Math.round((rebooked / sent) * 100); +} + +export interface DualRankingStats { + avgOrganic: number; + avgLocalPack: number; + improving: number; + topThreeMaps: number; +} + +/** + * Compute aggregate ranking stats from dual-rank data. + * - avgOrganic / avgLocalPack: mean of this-week ranks (1 decimal). + * - improving: count of keywords whose organic rank improved (delta > 0). + * - topThreeMaps: count of keywords in Local Pack top 3 (rank <= 3). + */ +export function dualRankingStats(rankings: DualRanking[]): DualRankingStats { + if (rankings.length === 0) { + return { avgOrganic: 0, avgLocalPack: 0, improving: 0, topThreeMaps: 0 }; + } + const sumOrganic = rankings.reduce((s, r) => s + r.organicThisWeek, 0); + const sumLocalPack = rankings.reduce((s, r) => s + r.localPackThisWeek, 0); + return { + avgOrganic: round1(sumOrganic / rankings.length), + avgLocalPack: round1(sumLocalPack / rankings.length), + improving: rankings.filter((r) => r.organicDelta > 0).length, + topThreeMaps: rankings.filter((r) => r.localPackThisWeek <= 3).length, + }; +} + +export interface LocationStats { + total: number; + synced: number; +} + +/** + * Count total locations and how many have menu-sync configured. + */ +export function locationStats(locations: Location[]): LocationStats { + return { + total: locations.length, + synced: locations.filter((l) => l.status === "synced").length, + }; +} + +export type DeltaDirection = "up" | "down" | "none"; + +export interface RankDelta { + direction: DeltaDirection; + /** Absolute magnitude of the change. */ + magnitude: number; +} + +/** + * Classify a rank delta. + * Positive delta = rank improved (moved up, lower number is better). + */ +export function rankDelta(delta: number): RankDelta { + if (delta > 0) return { direction: "up", magnitude: delta }; + if (delta < 0) return { direction: "down", magnitude: Math.abs(delta) }; + return { direction: "none", magnitude: 0 }; +} + +function round1(n: number): number { + return Math.round(n * 10) / 10; +} diff --git a/dashboard/lib/stubs.ts b/dashboard/lib/stubs.ts index 58bbb46..0396034 100644 --- a/dashboard/lib/stubs.ts +++ b/dashboard/lib/stubs.ts @@ -170,3 +170,319 @@ export const stubUserClient: UserClient = { subscription_status: "trial", trial_usage: { review_drafts: 3, rankings: 45, competitors: 2 }, }; + +// --- Phase 5: Billing & Usage --- + +/** Per-job usage with used amount and plan cap. */ +export interface UsageBar { + label: string; + used: number; + cap: number; +} + +/** Stored payment card, if one is on file. */ +export interface PlanCard { + brand: string; + last4: string; + expires: string; + valid: boolean; +} + +/** Active plan details returned by GET /billing/usage. */ +export interface PlanInfo { + name: string; + status: "active" | "trial" | "trial_expired"; + price: string; + renews_at: string; + days_left: number; + card: PlanCard | null; + billing_email: string; + next_invoice: { amount: string; date: string }; +} + +/** A single line in the invoice history table. */ +export interface Invoice { + id: string; + date: string; + amount: string; + status: "paid" | "open" | "void"; +} + +/** Aggregate billing/usage payload returned by GET /billing/usage. */ +export interface BillingUsage { + plan: PlanInfo; + usage: UsageBar[]; + invoices: Invoice[]; +} + +/** Stripe billing-portal session returned by POST /billing/portal. */ +export interface BillingPortal { + url: string; +} + +export const stubPlanInfo: PlanInfo = { + name: "Growth", + status: "active", + price: "A$149 / month", + renews_at: "1 Aug 2026", + days_left: 11, + card: { brand: "Visa", last4: "4242", expires: "08 / 2027", valid: true }, + billing_email: "accounts@bondidental.com.au", + next_invoice: { amount: "A$149.00", date: "1 Aug 2026" }, +}; + +export const stubUsageBars: UsageBar[] = [ + { label: "Review drafts", used: 212, cap: 500 }, + { label: "SEO reports", used: 6, cap: 8 }, + { label: "Competitor briefs", used: 4, cap: 5 }, + { label: "Follow-up messages", used: 188, cap: 250 }, +]; + +export const stubInvoices: Invoice[] = [ + { id: "inv_2026_07", date: "1 Jul 2026", amount: "A$149.00", status: "paid" }, + { id: "inv_2026_06", date: "1 Jun 2026", amount: "A$149.00", status: "paid" }, + { id: "inv_2026_05", date: "1 May 2026", amount: "A$149.00", status: "paid" }, +]; + +export const stubBillingUsage: BillingUsage = { + plan: stubPlanInfo, + usage: stubUsageBars, + invoices: stubInvoices, +}; + +/* ------------------------------------------------------------------ */ +/* Phase 5 — Locations, Rebook, Dual-rank reports, Competitor diffs */ +/* ------------------------------------------------------------------ */ + +export interface MenuSyncTarget { + key: string; + label: string; + subtitle: string; + enabled: boolean; +} + +export interface Location { + id: string; + name: string; + area: string; + menuSyncTarget: string; + status: "synced" | "setup_needed"; + lastSync: string; + targets: MenuSyncTarget[]; +} + +export interface LapsedPatient { + name: string; + lastVisit: string; + channel: string; + status: "rebooked" | "sent" | "queued" | "opted_out"; +} + +export interface Practitioner { + id: string; + name: string; + specialty: string; + lapsed: number; + sent: number; + rebooked: number; + autoFollowUp: boolean; + patients: LapsedPatient[]; +} + +export interface DualRanking { + keyword: string; + organicThisWeek: number; + organicDelta: number; + localPackThisWeek: number; + localPackDelta: number; +} + +export interface StructuredDiff { + type: "price" | "menu" | "hours"; + description: string; + oldValue: string; + newValue: string; + timestamp: string; +} + +export interface CompetitorChange { + name: string; + domain: string; + threat: "low" | "medium" | "high"; + changes: StructuredDiff[]; +} + +export const stubLocations: Location[] = [ + { + id: "loc1", + name: "Bondi Dental — Bondi Junction", + area: "Bondi Junction, NSW", + menuSyncTarget: "Google Business Profile + HealthEngine", + status: "synced", + lastSync: "5m ago", + targets: [ + { key: "gbp", label: "Google Business Profile", subtitle: "Services & hours", enabled: true }, + { key: "healthengine", label: "HealthEngine", subtitle: "12 services mapped", enabled: true }, + { key: "doctify", label: "Doctify", subtitle: "Not connected", enabled: false }, + { key: "website", label: "Website", subtitle: "Auto-embed", enabled: true }, + ], + }, + { + id: "loc2", + name: "Bondi Dental — Surry Hills", + area: "Surry Hills, NSW", + menuSyncTarget: "Google Business Profile + HealthEngine", + status: "synced", + lastSync: "5m ago", + targets: [ + { key: "gbp", label: "Google Business Profile", subtitle: "Services & hours", enabled: true }, + { key: "healthengine", label: "HealthEngine", subtitle: "10 services mapped", enabled: true }, + { key: "doctify", label: "Doctify", subtitle: "Not connected", enabled: false }, + { key: "website", label: "Website", subtitle: "Auto-embed", enabled: true }, + ], + }, + { + id: "loc3", + name: "Bondi Dental — Chatswood", + area: "Chatswood, NSW", + menuSyncTarget: "Google Business Profile", + status: "synced", + lastSync: "12m ago", + targets: [ + { key: "gbp", label: "Google Business Profile", subtitle: "Services & hours", enabled: true }, + { key: "healthengine", label: "HealthEngine", subtitle: "Not connected", enabled: false }, + { key: "doctify", label: "Doctify", subtitle: "Not connected", enabled: false }, + { key: "website", label: "Website", subtitle: "Auto-embed", enabled: true }, + ], + }, + { + id: "loc4", + name: "Bondi Dental — Parramatta", + area: "Parramatta, NSW", + menuSyncTarget: "Google Business Profile + Doctify", + status: "synced", + lastSync: "1h ago", + targets: [ + { key: "gbp", label: "Google Business Profile", subtitle: "Services & hours", enabled: true }, + { key: "healthengine", label: "HealthEngine", subtitle: "Not connected", enabled: false }, + { key: "doctify", label: "Doctify", subtitle: "8 services mapped", enabled: true }, + { key: "website", label: "Website", subtitle: "Auto-embed", enabled: true }, + ], + }, + { + id: "loc5", + name: "Bondi Dental — Newtown", + area: "Newtown, NSW", + menuSyncTarget: "Google Business Profile", + status: "synced", + lastSync: "3h ago", + targets: [ + { key: "gbp", label: "Google Business Profile", subtitle: "Services & hours", enabled: true }, + { key: "healthengine", label: "HealthEngine", subtitle: "Not connected", enabled: false }, + { key: "doctify", label: "Doctify", subtitle: "Not connected", enabled: false }, + { key: "website", label: "Website", subtitle: "Auto-embed", enabled: true }, + ], + }, + { + id: "loc6", + name: "Bondi Dental — Manly", + area: "Manly, NSW", + menuSyncTarget: "Not configured", + status: "setup_needed", + lastSync: "—", + targets: [ + { key: "gbp", label: "Google Business Profile", subtitle: "Not connected", enabled: false }, + { key: "healthengine", label: "HealthEngine", subtitle: "Not connected", enabled: false }, + { key: "doctify", label: "Doctify", subtitle: "Not connected", enabled: false }, + { key: "website", label: "Website", subtitle: "Auto-embed", enabled: false }, + ], + }, +]; + +export const stubPractitioners: Practitioner[] = [ + { + id: "p1", + name: "Dr Sarah Chen", + specialty: "General dentistry", + lapsed: 24, + sent: 15, + rebooked: 6, + autoFollowUp: true, + patients: [ + { name: "J. Adams", lastVisit: "Nov 2025", channel: "SMS", status: "rebooked" }, + { name: "T. Nguyen", lastVisit: "Oct 2025", channel: "Email", status: "sent" }, + { name: "Y. Patel", lastVisit: "Sep 2025", channel: "SMS", status: "queued" }, + { name: "R. Williams", lastVisit: "Dec 2025", channel: "—", status: "opted_out" }, + ], + }, + { + id: "p2", + name: "Dr James Wilson", + specialty: "Orthodontics", + lapsed: 31, + sent: 22, + rebooked: 9, + autoFollowUp: true, + patients: [ + { name: "L. Garcia", lastVisit: "Oct 2025", channel: "SMS", status: "rebooked" }, + { name: "M. Thompson", lastVisit: "Aug 2025", channel: "Email", status: "sent" }, + { name: "K. O'Brien", lastVisit: "Jul 2025", channel: "SMS", status: "queued" }, + ], + }, + { + id: "p3", + name: "Dr Emily Tran", + specialty: "Hygiene", + lapsed: 18, + sent: 10, + rebooked: 3, + autoFollowUp: false, + patients: [ + { name: "D. Kim", lastVisit: "Nov 2025", channel: "SMS", status: "rebooked" }, + { name: "S. Murphy", lastVisit: "Sep 2025", channel: "Email", status: "sent" }, + ], + }, + { + id: "p4", + name: "Dr Michael Brown", + specialty: "Implants", + lapsed: 13, + sent: 5, + rebooked: 1, + autoFollowUp: true, + patients: [ + { name: "A. Rossi", lastVisit: "Oct 2025", channel: "Email", status: "rebooked" }, + { name: "C. Davies", lastVisit: "Aug 2025", channel: "SMS", status: "sent" }, + ], + }, +]; + +export const stubDualRankings: DualRanking[] = [ + { keyword: "dentist Bondi", organicThisWeek: 2, organicDelta: 1, localPackThisWeek: 1, localPackDelta: 1 }, + { keyword: "teeth whitening Sydney", organicThisWeek: 11, organicDelta: -3, localPackThisWeek: 6, localPackDelta: -1 }, + { keyword: "emergency dentist", organicThisWeek: 5, organicDelta: 0, localPackThisWeek: 2, localPackDelta: 1 }, + { keyword: "dental implant", organicThisWeek: 7, organicDelta: 5, localPackThisWeek: 4, localPackDelta: 5 }, + { keyword: "invisalign", organicThisWeek: 6, organicDelta: 3, localPackThisWeek: 7, localPackDelta: 0 }, +]; + +export const stubCompetitorChanges: CompetitorChange[] = [ + { + name: "Bondi Beach Dental", + domain: "bondibeachdental.com.au", + threat: "high", + changes: [ + { type: "price", description: "Teeth whitening price changed", oldValue: "A$450", newValue: "A$390", timestamp: "2h ago" }, + { type: "menu", description: "Added service: Same-day emergency", oldValue: "Not offered", newValue: "Now offered", timestamp: "2h ago" }, + { type: "price", description: "Consultation fee changed", oldValue: "A$95", newValue: "A$120", timestamp: "1d ago" }, + ], + }, + { + name: "Sydney Smiles Dental", + domain: "sydneysmiles.com.au", + threat: "medium", + changes: [ + { type: "menu", description: "Menu item added: Invisalign Lite", oldValue: "—", newValue: "A$2,900", timestamp: "6h ago" }, + { type: "hours", description: "Opening hours updated", oldValue: "Sat closed", newValue: "Sat 9–13", timestamp: "3d ago" }, + ], + }, +]; diff --git a/dashboard/lib/usage.test.ts b/dashboard/lib/usage.test.ts new file mode 100644 index 0000000..475b664 --- /dev/null +++ b/dashboard/lib/usage.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { + usagePercent, + usageStatus, + usageRemaining, + findUsageBar, + getCap, + STATUS_STYLES, + REVIEW_DRAFTS, +} from "./usage"; +import type { UsageBar } from "./stubs"; + +const bar = (used: number, cap: number): UsageBar => ({ + label: "Review drafts", + used, + cap, +}); + +describe("usagePercent", () => { + it("computes a rounded percentage", () => { + expect(usagePercent(bar(212, 500))).toBe(42); + expect(usagePercent(bar(6, 8))).toBe(75); + expect(usagePercent(bar(4, 5))).toBe(80); + }); + + it("caps at 100", () => { + expect(usagePercent(bar(600, 500))).toBe(100); + }); + + it("returns 0 for zero or negative caps", () => { + expect(usagePercent(bar(5, 0))).toBe(0); + expect(usagePercent(bar(5, -1))).toBe(0); + }); +}); + +describe("usageStatus", () => { + it("is ok below 80%", () => { + expect(usageStatus(bar(0, 100))).toBe("ok"); + expect(usageStatus(bar(79, 100))).toBe("ok"); + }); + + it("is near at 80% inclusive up to (but not including) 100%", () => { + expect(usageStatus(bar(80, 100))).toBe("near"); + expect(usageStatus(bar(99, 100))).toBe("near"); + }); + + it("is over at 100% and above", () => { + expect(usageStatus(bar(100, 100))).toBe("over"); + expect(usageStatus(bar(150, 100))).toBe("over"); + }); + + it("is over when the cap is zero or negative", () => { + expect(usageStatus(bar(0, 0))).toBe("over"); + expect(usageStatus(bar(0, -5))).toBe("over"); + }); +}); + +describe("usageRemaining", () => { + it("returns cap - used", () => { + expect(usageRemaining(bar(212, 500))).toBe(288); + }); + + it("never goes negative", () => { + expect(usageRemaining(bar(600, 500))).toBe(0); + }); +}); + +describe("findUsageBar / getCap", () => { + const usage: UsageBar[] = [ + { label: "Review drafts", used: 212, cap: 500 }, + { label: "SEO reports", used: 6, cap: 8 }, + ]; + + it("finds a bar by label, case-insensitively", () => { + expect(findUsageBar(usage, "review drafts")?.cap).toBe(500); + expect(findUsageBar(usage, "SEO Reports")?.cap).toBe(8); + }); + + it("returns undefined for a missing label", () => { + expect(findUsageBar(usage, "Nope")).toBeUndefined(); + }); + + it("getCap returns the cap or undefined", () => { + expect(getCap(usage, REVIEW_DRAFTS)).toBe(500); + expect(getCap(usage, "Missing")).toBeUndefined(); + }); +}); + +describe("STATUS_STYLES", () => { + it("exposes a label for every status", () => { + expect(STATUS_STYLES.ok.label).toBe("OK"); + expect(STATUS_STYLES.near.label).toBe("Near cap"); + expect(STATUS_STYLES.over.label).toBe("Over cap"); + }); + + it("every status has non-empty badge and bar classes", () => { + for (const key of ["ok", "near", "over"] as const) { + expect(STATUS_STYLES[key].badge).toBeTruthy(); + expect(STATUS_STYLES[key].bar).toBeTruthy(); + } + }); +}); diff --git a/dashboard/lib/usage.ts b/dashboard/lib/usage.ts new file mode 100644 index 0000000..6949d08 --- /dev/null +++ b/dashboard/lib/usage.ts @@ -0,0 +1,63 @@ +import type { UsageBar } from "@/lib/stubs"; + +export type UsageStatus = "ok" | "near" | "over"; + +/** Label used for the review-drafts quota (shared with TrialBanner). */ +export const REVIEW_DRAFTS = "Review drafts"; + +/** + * Rounded usage percentage, capped at 100. Returns 0 when a cap is not + * positive so the progress bar never divides by zero. + */ +export function usagePercent(bar: UsageBar): number { + if (bar.cap <= 0) return 0; + return Math.min(100, Math.round((bar.used / bar.cap) * 100)); +} + +/** + * Bucket a usage bar into a status. Thresholds mirror the design mockups: + * >= 100% is over cap, >= 80% is near cap, otherwise OK. + */ +export function usageStatus(bar: UsageBar): UsageStatus { + if (bar.cap <= 0) return "over"; + const pct = (bar.used / bar.cap) * 100; + if (pct >= 100) return "over"; + if (pct >= 80) return "near"; + return "ok"; +} + +/** Remaining quota for the cycle, never negative. */ +export function usageRemaining(bar: UsageBar): number { + return Math.max(0, bar.cap - bar.used); +} + +/** Case-insensitive lookup of a usage bar by label. */ +export function findUsageBar( + usage: UsageBar[], + label: string +): UsageBar | undefined { + return usage.find((b) => b.label.toLowerCase() === label.toLowerCase()); +} + +/** Convenience accessor for a bar's cap, or undefined when not found. */ +export function getCap(usage: UsageBar[], label: string): number | undefined { + return findUsageBar(usage, label)?.cap; +} + +/** Presentation metadata for each usage status (badge label + Tailwind classes). */ +export const STATUS_STYLES: Record< + UsageStatus, + { label: string; badge: string; bar: string } +> = { + ok: { label: "OK", badge: "bg-chart-3/10 text-chart-3", bar: "bg-chart-3" }, + near: { + label: "Near cap", + badge: "bg-muted text-chart-2", + bar: "bg-chart-2", + }, + over: { + label: "Over cap", + badge: "bg-destructive/10 text-destructive", + bar: "bg-destructive", + }, +}; diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 528b756..f9ec058 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -26,7 +26,8 @@ "eslint": "^9", "eslint-config-next": "16.2.9", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -1252,6 +1253,303 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1259,6 +1557,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1550,6 +1855,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2300,6 +2623,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -2533,6 +2969,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2743,6 +3189,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3165,6 +3621,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3647,6 +4110,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3657,6 +4130,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3798,6 +4281,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5168,9 +5666,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -5441,6 +5939,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5549,6 +6061,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5579,9 +6098,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", "dev": true, "funding": [ { @@ -5599,7 +6118,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5787,6 +6306,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6088,6 +6641,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6104,6 +6664,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6325,6 +6899,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6373,6 +6964,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6665,6 +7266,200 @@ "punycode": "^2.1.0" } }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6770,6 +7565,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 56f0515..c30c9db 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@vercel/analytics": "^2.0.1", @@ -27,6 +29,7 @@ "eslint": "^9", "eslint-config-next": "16.2.9", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } } diff --git a/dashboard/vitest.config.ts b/dashboard/vitest.config.ts new file mode 100644 index 0000000..6447831 --- /dev/null +++ b/dashboard/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, + test: { + environment: "node", + }, +});