Redesign marketing pages and isolate public routes from app auth.

Give the landing, legal, and sign-in flows a consistent product shell while keeping marketing pages free of tRPC/session calls, fixing dev auth URL handling, and refreshing env and deploy docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 02:00:06 -04:00
co-authored by Cursor
parent 82977b6dd8
commit 6ec26a4a0d
36 changed files with 1743 additions and 819 deletions
+71
View File
@@ -0,0 +1,71 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Logo } from "~/components/branding/logo";
import { cn } from "~/lib/utils";
export function AuthPageShell({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="bg-dashboard text-foreground flex min-h-screen flex-col px-5 py-6 sm:px-6 sm:py-8">
<div
className={cn(
"mx-auto flex w-full max-w-md flex-1 flex-col justify-center",
className,
)}
>
<Link
href="/"
className="text-muted-foreground hover:text-foreground mb-6 inline-flex items-center gap-2 text-sm transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to home
</Link>
{children}
</div>
</div>
);
}
export function AuthCard({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"border-border/50 bg-background/80 rounded-3xl border p-6 shadow-xl backdrop-blur-xl sm:p-8",
className,
)}
>
{children}
</div>
);
}
export function AuthCardHeader({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="mb-6 space-y-3">
<Logo size="md" animated={false} />
<div className="space-y-1">
<h1 className="font-heading text-2xl font-semibold tracking-tight">
{title}
</h1>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
/** Grid + animated blob backdrop shared by the app shell and marketing pages. */
export function BrandBackground() {
return (
<div className="brand-background pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]" />
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30" />
</div>
);
}
+12 -4
View File
@@ -66,7 +66,7 @@ function LegalSectionBlock({
return (
<section
id={section.id}
className={cn("scroll-mt-24", !isLast && "border-border border-b")}
className={cn("scroll-mt-28", !isLast && "border-border/50 border-b")}
>
<div className="px-6 pt-8 pb-4 sm:px-8">
<h2 className="text-foreground text-lg font-semibold tracking-tight sm:text-xl">
@@ -82,12 +82,20 @@ function LegalSectionBlock({
export function LegalDocument({ sections }: { sections: LegalSection[] }) {
return (
<div className="grid gap-8 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)] lg:items-start">
<aside className="bg-card border-border rounded-lg border p-4 lg:sticky lg:top-8">
<div className="grid gap-6 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)] lg:items-start lg:gap-8">
<aside
className={cn(
"border-border/60 bg-card/70 rounded-2xl border p-4 backdrop-blur-sm lg:sticky lg:top-24",
)}
>
<LegalTableOfContents sections={sections} />
</aside>
<article className="bg-card border-border overflow-hidden rounded-lg border shadow-sm">
<article
className={cn(
"border-border/60 bg-card/70 overflow-hidden rounded-3xl border shadow-xl backdrop-blur-sm",
)}
>
{sections.map((section, index) => (
<LegalSectionBlock
key={section.id}
+34 -32
View File
@@ -1,9 +1,12 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Logo } from "~/components/branding/logo";
import {
MarketingFooter,
MarketingHeader,
MarketingPageShell,
marketingSurfaceClass,
} from "~/components/marketing/marketing-chrome";
import { LEGAL_LAST_UPDATED } from "~/lib/legal";
import { cn } from "~/lib/utils";
import { env } from "~/env";
type LegalPageShellProps = {
title: string;
@@ -16,35 +19,34 @@ export function LegalPageShell({
description,
children,
}: LegalPageShellProps) {
const allowRegistration = env.DISABLE_SIGNUPS !== true;
return (
<div className="bg-background min-h-screen">
<header className="border-border bg-card/80 border-b backdrop-blur-sm">
<div className="container mx-auto flex max-w-6xl flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<Logo size="sm" />
<Link href="/">
<Button variant="outline" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to app
</Button>
</Link>
</div>
<MarketingPageShell>
<MarketingHeader allowRegistration={allowRegistration} />
<div className="max-w-3xl space-y-2">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">{title}</h1>
{description ? (
<p className="text-muted-foreground text-base leading-relaxed">{description}</p>
) : null}
<p className="text-muted-foreground text-sm">
Last updated {LEGAL_LAST_UPDATED}
</p>
</div>
</div>
</header>
<div
className={cn(
marketingSurfaceClass,
"mb-8 space-y-3 px-6 py-8 sm:px-8 sm:py-10",
)}
>
<h1 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
{title}
</h1>
{description ? (
<p className="text-muted-foreground max-w-3xl text-base leading-7">
{description}
</p>
) : null}
<p className="text-muted-foreground text-sm">
Last updated {LEGAL_LAST_UPDATED}
</p>
</div>
<main className="container mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
{children}
</main>
</div>
<main>{children}</main>
<MarketingFooter />
</MarketingPageShell>
);
}
@@ -0,0 +1,265 @@
import {
BarChart3,
Clock,
FileText,
LayoutDashboard,
Receipt,
Settings,
Timer,
Users,
} from "lucide-react";
import { BrowserFrame } from "~/components/marketing/browser-frame";
import { cn } from "~/lib/utils";
function MockSidebar({ active }: { active: "dashboard" | "invoices" | "time" }) {
const items = [
{ id: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
{ id: "invoices" as const, label: "Invoices", icon: FileText },
{ id: "time" as const, label: "Time clock", icon: Timer },
{ id: "clients" as const, label: "Clients", icon: Users },
{ id: "expenses" as const, label: "Expenses", icon: Receipt },
{ id: "reports" as const, label: "Reports", icon: BarChart3 },
];
return (
<aside className="bg-card/90 hidden w-36 shrink-0 border-r p-3 sm:block">
<div className="text-primary mb-4 font-mono text-xs font-bold">$ beenvoice</div>
<nav className="space-y-0.5">
{items.map((item) => {
const Icon = item.icon;
const isActive = item.id === active;
return (
<div
key={item.id}
className={cn(
"flex items-center gap-2 rounded-lg px-2 py-1.5 text-[10px] font-medium",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground",
)}
>
<Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{item.label}</span>
</div>
);
})}
</nav>
<div className="text-muted-foreground mt-6 flex items-center gap-2 px-2 text-[10px]">
<Settings className="h-3 w-3" />
Settings
</div>
</aside>
);
}
function StatusBadge({
children,
tone,
}: {
children: React.ReactNode;
tone: "draft" | "sent" | "paid" | "overdue";
}) {
const tones = {
draft: "bg-muted text-muted-foreground",
sent: "bg-blue-500/10 text-blue-700 dark:text-blue-300",
paid: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
overdue: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
};
return (
<span
className={cn(
"inline-flex rounded-full px-2 py-0.5 text-[9px] font-medium",
tones[tone],
)}
>
{children}
</span>
);
}
export function InvoicesScreenshot({ className }: { className?: string }) {
const rows = [
{ client: "Northwind Studio", id: "INV-1042", amount: "$1,850.00", status: "sent" as const },
{ client: "Harbor & Co.", id: "INV-1041", amount: "$640.00", status: "paid" as const },
{ client: "Lumen Creative", id: "INV-1040", amount: "$2,100.00", status: "draft" as const },
{ client: "Field Notes Ltd", id: "INV-1039", amount: "$420.00", status: "overdue" as const },
];
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard/invoices">
<div className="flex min-h-[280px] sm:min-h-[320px]">
<MockSidebar active="invoices" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h3 className="font-heading text-sm font-semibold sm:text-base">
Invoices
</h3>
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
Draft, send, and track what you&apos;re owed.
</p>
</div>
<div className="bg-primary text-primary-foreground rounded-lg px-2.5 py-1 text-[10px] font-medium">
New invoice
</div>
</div>
<div className="bg-card overflow-hidden rounded-xl border">
<div className="text-muted-foreground grid grid-cols-[1fr_auto_auto] gap-2 border-b px-3 py-2 text-[9px] font-medium uppercase tracking-wide sm:grid-cols-[1.2fr_0.8fr_auto_auto] sm:px-4">
<span>Client</span>
<span className="hidden sm:block">Invoice</span>
<span>Amount</span>
<span>Status</span>
</div>
{rows.map((row) => (
<div
key={row.id}
className="grid grid-cols-[1fr_auto_auto] items-center gap-2 border-b px-3 py-2.5 text-[10px] last:border-0 sm:grid-cols-[1.2fr_0.8fr_auto_auto] sm:px-4 sm:text-xs"
>
<span className="truncate font-medium">{row.client}</span>
<span className="text-muted-foreground hidden sm:block">{row.id}</span>
<span className="tabular-nums">{row.amount}</span>
<StatusBadge tone={row.status}>
{row.status.charAt(0).toUpperCase() + row.status.slice(1)}
</StatusBadge>
</div>
))}
</div>
</div>
</div>
</BrowserFrame>
);
}
export function TimeClockScreenshot({ className }: { className?: string }) {
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard/time-clock">
<div className="flex min-h-[260px] sm:min-h-[300px]">
<MockSidebar active="time" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
<div className="mb-4">
<h3 className="font-heading text-sm font-semibold sm:text-base">
Time clock
</h3>
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
Track billable hours and roll them into invoices.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="bg-card rounded-xl border p-4">
<div className="text-muted-foreground mb-2 flex items-center gap-1.5 text-[10px] font-medium">
<Timer className="h-3 w-3" />
Active session
</div>
<div className="font-heading text-2xl font-semibold tabular-nums sm:text-3xl">
02:14:38
</div>
<p className="text-muted-foreground mt-1 text-[10px]">
Brand refresh Northwind Studio
</p>
<div className="mt-4 flex gap-2">
<div className="bg-primary text-primary-foreground rounded-lg px-3 py-1.5 text-[10px] font-medium">
Stop
</div>
<div className="border-border rounded-lg border px-3 py-1.5 text-[10px] font-medium">
Pause
</div>
</div>
</div>
<div className="bg-card rounded-xl border p-4">
<div className="text-muted-foreground mb-3 flex items-center gap-1.5 text-[10px] font-medium">
<Clock className="h-3 w-3" />
Recent entries
</div>
<div className="space-y-2.5">
{[
{ label: "Wireframes", time: "1h 20m", client: "Harbor & Co." },
{ label: "Copy edits", time: "45m", client: "Lumen Creative" },
{ label: "Kickoff call", time: "30m", client: "Field Notes Ltd" },
].map((entry) => (
<div
key={entry.label}
className="flex items-center justify-between gap-2 text-[10px] sm:text-xs"
>
<div className="min-w-0">
<p className="truncate font-medium">{entry.label}</p>
<p className="text-muted-foreground truncate">{entry.client}</p>
</div>
<span className="text-muted-foreground shrink-0 tabular-nums">
{entry.time}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</BrowserFrame>
);
}
export function DashboardScreenshot({ className }: { className?: string }) {
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard">
<div className="flex min-h-[260px] sm:min-h-[300px]">
<MockSidebar active="dashboard" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
<div className="mb-4">
<h3 className="font-heading text-sm font-semibold sm:text-base">
Good afternoon
</h3>
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
Here&apos;s what needs your attention.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="bg-card rounded-xl border p-4">
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
Awaiting payment
</p>
<p className="font-heading mt-2 text-lg font-semibold">3 invoices</p>
<p className="text-muted-foreground mt-1 text-[10px]">
Follow up on sent invoices when you&apos;re ready.
</p>
</div>
<div className="bg-card rounded-xl border p-4">
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
Timer running
</p>
<p className="font-heading mt-2 text-lg font-semibold tabular-nums">
02:14:38
</p>
<p className="text-muted-foreground mt-1 text-[10px]">
Northwind Studio · Brand refresh
</p>
</div>
</div>
<div className="bg-card mt-3 rounded-xl border p-4">
<p className="mb-3 text-[10px] font-medium">Recent activity</p>
<div className="space-y-2">
{[
"Invoice INV-1042 sent to Northwind Studio",
"Timer started for Brand refresh",
"Client Harbor & Co. updated",
].map((line) => (
<div
key={line}
className="text-muted-foreground flex items-center gap-2 text-[10px] sm:text-xs"
>
<span className="bg-primary/60 h-1.5 w-1.5 shrink-0 rounded-full" />
<span className="truncate">{line}</span>
</div>
))}
</div>
</div>
</div>
</div>
</BrowserFrame>
);
}
@@ -0,0 +1,32 @@
import { cn } from "~/lib/utils";
export function BrowserFrame({
children,
className,
url = "beenvoice.app/dashboard",
}: {
children: React.ReactNode;
className?: string;
url?: string;
}) {
return (
<div
className={cn(
"border-border/80 bg-card overflow-hidden rounded-2xl border shadow-2xl shadow-black/8 ring-1 ring-black/5",
className,
)}
>
<div className="bg-muted/50 flex items-center gap-3 border-b px-4 py-2.5">
<div className="flex gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f57]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#febc2e]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#28c840]" />
</div>
<div className="bg-background/70 text-muted-foreground mx-auto flex h-7 w-full max-w-sm items-center justify-center rounded-lg px-3 text-[11px] tracking-wide">
{url}
</div>
</div>
<div className="bg-dashboard overflow-hidden">{children}</div>
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
import Link from "next/link";
import {
ArrowRight,
Clock,
FileText,
Mail,
Receipt,
Repeat,
Timer,
Users,
} from "lucide-react";
import {
DashboardScreenshot,
InvoicesScreenshot,
TimeClockScreenshot,
} from "~/components/marketing/app-screenshots";
import {
MarketingFooter,
MarketingHeader,
MarketingPageShell,
marketingSurfaceClass,
} from "~/components/marketing/marketing-chrome";
import { Button } from "~/components/ui/button";
import { brand } from "~/lib/branding";
import { cn } from "~/lib/utils";
const features = [
{
icon: FileText,
title: "Invoices that stay organized",
description:
"Draft line items, apply taxes, send a link, and export a polished PDF when you need a file.",
},
{
icon: Users,
title: "Clients and businesses",
description:
"Keep the people and companies you bill in one place, with the details you reuse on every invoice.",
},
{
icon: Timer,
title: "Built-in time clock",
description:
"Track billable hours as you work, then pull them straight into an invoice without retyping.",
},
{
icon: Repeat,
title: "Recurring invoices",
description:
"Set up retainers and subscriptions once, then let beenvoice generate the next invoice on schedule.",
},
{
icon: Receipt,
title: "Expenses",
description:
"Log costs alongside your work so nothing gets lost before you bill it out.",
},
{
icon: Mail,
title: "Send and follow up",
description:
"Email invoices from the app and keep status visible from draft through paid.",
},
];
function FeatureRow({
title,
description,
children,
reverse = false,
}: {
title: string;
description: string;
children: React.ReactNode;
reverse?: boolean;
}) {
return (
<div
className={cn(
"grid items-center gap-10 lg:grid-cols-2 lg:gap-16",
reverse && "lg:[&>*:first-child]:order-2",
)}
>
<div className="space-y-4">
<h3 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
{title}
</h3>
<p className="text-muted-foreground max-w-lg text-base leading-7">
{description}
</p>
</div>
<div>{children}</div>
</div>
);
}
export function LandingPage({ allowRegistration }: { allowRegistration: boolean }) {
return (
<MarketingPageShell>
<MarketingHeader allowRegistration={allowRegistration} />
<section className="pb-16 sm:pb-20 lg:pb-24">
<div className="mx-auto max-w-3xl text-center">
<p className="text-primary mb-4 text-sm font-medium tracking-wide uppercase">
Personal invoicing workspace
</p>
<h1 className="font-heading text-4xl leading-[1.1] font-bold tracking-tight sm:text-5xl lg:text-6xl">
Run your freelance admin from one place.
</h1>
<p className="text-muted-foreground mx-auto mt-5 max-w-2xl text-base leading-7 sm:text-lg">
{brand.name} helps you manage clients, track time, send invoices,
and stay on top of getting paid without the weight of a full
accounting suite.
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link href="/auth/signin">
<Button size="lg" className="h-11 px-6">
Open workspace
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
{allowRegistration && (
<Link href="/auth/register">
<Button variant="outline" size="lg" className="h-11 px-6">
Create account
</Button>
</Link>
)}
</div>
</div>
<div className="relative mx-auto mt-14 max-w-5xl lg:mt-16">
<InvoicesScreenshot className="w-full" />
</div>
</section>
<section className="border-border/50 border-t py-16 sm:py-20">
<div className="mx-auto max-w-2xl text-center">
<h2 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
Everything around getting paid, in one flow
</h2>
<p className="text-muted-foreground mt-4 text-base leading-7">
{brand.tagline}. Built for one person doing real client work not
enterprise dashboards you&apos;ll never open.
</p>
</div>
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{features.map((feature) => {
const Icon = feature.icon;
return (
<div
key={feature.title}
className="bg-card/70 hover:bg-card/90 border-border/60 rounded-2xl border p-5 backdrop-blur-sm transition-colors"
>
<div className="bg-primary/10 text-primary mb-4 inline-flex rounded-xl p-2.5">
<Icon className="h-4 w-4" />
</div>
<h3 className="text-sm font-semibold sm:text-base">
{feature.title}
</h3>
<p className="text-muted-foreground mt-2 text-sm leading-6">
{feature.description}
</p>
</div>
);
})}
</div>
</section>
<section className="border-border/50 space-y-20 border-t py-16 sm:space-y-24 sm:py-20">
<FeatureRow
title="See your week at a glance"
description="Open the dashboard to check what's waiting on payment, whether a timer is still running, and what changed recently — without wading through reports."
>
<DashboardScreenshot />
</FeatureRow>
<FeatureRow
title="Track time where you already work"
description="Start a timer for the client and project you're on. When the work is done, turn those hours into invoice line items in a few clicks."
reverse
>
<TimeClockScreenshot />
</FeatureRow>
<FeatureRow
title="Invoices that look professional"
description="Clean layouts, PDF export, and a shareable link for clients. Mark invoices sent or paid as your pipeline moves."
>
<InvoicesScreenshot />
</FeatureRow>
</section>
<section>
<div
className={cn(
marketingSurfaceClass,
"bg-card/80 relative overflow-hidden px-6 py-10 text-center sm:px-10 sm:py-12",
)}
>
<div className="relative">
<div className="bg-primary/10 text-primary mx-auto mb-4 inline-flex rounded-full p-3">
<Clock className="h-5 w-5" />
</div>
<h2 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Ready to simplify your invoicing?
</h2>
<p className="text-muted-foreground mx-auto mt-3 max-w-xl text-sm leading-6 sm:text-base">
Sign in to your workspace or create an account to start with
clients, invoices, and time tracking in minutes.
</p>
<div className="mt-6 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link href="/auth/signin">
<Button size="lg" className="h-11 px-6">
Open workspace
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
{allowRegistration && (
<Link href="/auth/register">
<Button variant="outline" size="lg" className="h-11 px-6">
Create account
</Button>
</Link>
)}
</div>
</div>
</div>
</section>
<MarketingFooter />
</MarketingPageShell>
);
}
@@ -0,0 +1,84 @@
import Link from "next/link";
import { Logo } from "~/components/branding/logo";
import { Button } from "~/components/ui/button";
import { brand } from "~/lib/branding";
import { cn } from "~/lib/utils";
export const marketingSurfaceClass =
"border-border/50 bg-background/80 rounded-3xl border shadow-xl backdrop-blur-xl";
export function MarketingPageShell({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="bg-dashboard text-foreground relative min-h-screen">
<div className="mx-auto w-full max-w-6xl px-5 pt-4 pb-6 sm:px-6 sm:pt-5 sm:pb-8 lg:px-8">
{children}
</div>
</div>
);
}
export function MarketingHeader({
allowRegistration = true,
sticky = true,
}: {
allowRegistration?: boolean;
sticky?: boolean;
}) {
return (
<header
className={cn(
marketingSurfaceClass,
"mb-8 flex items-center justify-between gap-4 px-4 py-3 sm:px-5",
sticky && "sticky top-4 z-20 sm:top-5",
)}
>
<Link href="/">
<Logo animated={false} />
</Link>
<nav className="flex items-center gap-2">
<Link href="/auth/signin">
<Button variant="ghost" size="sm">
Sign in
</Button>
</Link>
{allowRegistration && (
<Link href="/auth/register">
<Button size="sm">Create account</Button>
</Link>
)}
</nav>
</header>
);
}
export function MarketingFooter({ className }: { className?: string }) {
return (
<footer
className={cn(
marketingSurfaceClass,
"text-muted-foreground mt-8 flex flex-col gap-3 px-4 py-4 text-sm sm:flex-row sm:items-center sm:justify-between sm:px-5",
className,
)}
>
<span>© 2026 {brand.name}</span>
<div className="flex gap-5">
<Link
href="/privacy"
className="hover:text-foreground transition-colors"
>
Privacy Policy
</Link>
<Link
href="/terms"
className="hover:text-foreground transition-colors"
>
Terms of Service
</Link>
</div>
</footer>
);
}
@@ -0,0 +1,235 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { api } from "~/trpc/react";
import {
AnimationPreferencesContext,
DEFAULT_PREFERS_REDUCED,
DEFAULT_SPEED,
applyPreferencesToDOM,
clampSpeed,
readLocalStorage,
writeLocalStorage,
type AnimationPreferencesContextValue,
type AnimationPreferencesProviderProps,
} from "~/components/providers/animation-preferences-provider";
type PartialPrefs = {
prefersReducedMotion?: boolean;
animationSpeedMultiplier?: number;
};
/** Dashboard animation preferences with tRPC sync. Must render inside TRPCReactProvider. */
export function AnimationPreferencesProviderSynced({
children,
initial,
autoSync = true,
}: AnimationPreferencesProviderProps & { autoSync?: boolean }) {
const updateMutation = api.settings.updateAnimationPreferences.useMutation();
const { data: serverPrefs } = api.settings.getAnimationPreferences.useQuery(
undefined,
{
refetchOnWindowFocus: false,
staleTime: 60_000,
retry: false,
},
);
const [prefersReducedMotion, setPrefersReducedMotion] = useState<boolean>(
initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED,
);
const [animationSpeedMultiplier, setAnimationSpeedMultiplier] =
useState<number>(
clampSpeed(initial?.animationSpeedMultiplier ?? DEFAULT_SPEED),
);
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
const pendingSyncRef = useRef<PartialPrefs | null>(null);
const isHydratedRef = useRef(false);
const serverHydratedRef = useRef(false);
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const stored = readLocalStorage();
const systemReduced = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches;
const finalPrefers =
stored?.prefersReducedMotion ??
initial?.prefersReducedMotion ??
systemReduced ??
DEFAULT_PREFERS_REDUCED;
const finalSpeed = clampSpeed(
stored?.animationSpeedMultiplier ??
initial?.animationSpeedMultiplier ??
DEFAULT_SPEED,
);
// eslint-disable-next-line react-hooks/set-state-in-effect
setPrefersReducedMotion(finalPrefers);
setAnimationSpeedMultiplier(finalSpeed);
applyPreferencesToDOM({
prefersReducedMotion: finalPrefers,
animationSpeedMultiplier: finalSpeed,
});
isHydratedRef.current = true;
}, [initial?.prefersReducedMotion, initial?.animationSpeedMultiplier]);
const performUpdate = useCallback(
(patch: PartialPrefs, opts?: { sync?: boolean }) => {
setIsUpdating(true);
setPrefersReducedMotion((prev) => patch.prefersReducedMotion ?? prev);
setAnimationSpeedMultiplier((prev) =>
clampSpeed(patch.animationSpeedMultiplier ?? prev),
);
const normalizedPatch: PartialPrefs = { ...patch };
if (
normalizedPatch.prefersReducedMotion === true &&
normalizedPatch.animationSpeedMultiplier === undefined &&
animationSpeedMultiplier !== 1
) {
normalizedPatch.animationSpeedMultiplier = 1;
}
const nextReduced =
normalizedPatch.prefersReducedMotion ?? prefersReducedMotion;
let nextSpeed = clampSpeed(
normalizedPatch.animationSpeedMultiplier ?? animationSpeedMultiplier,
);
if (nextReduced && nextSpeed !== 1) {
nextSpeed = 1;
normalizedPatch.animationSpeedMultiplier ??= 1;
}
const newPrefs = {
prefersReducedMotion: nextReduced,
animationSpeedMultiplier: nextSpeed,
};
applyPreferencesToDOM(newPrefs);
writeLocalStorage(newPrefs);
const shouldSync = opts?.sync ?? autoSync;
if (shouldSync && serverPrefs) {
pendingSyncRef.current = {
prefersReducedMotion: patch.prefersReducedMotion,
animationSpeedMultiplier: patch.animationSpeedMultiplier,
};
updateMutation.mutate(
{
...(normalizedPatch.prefersReducedMotion !== undefined && {
prefersReducedMotion: normalizedPatch.prefersReducedMotion,
}),
...(normalizedPatch.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: clampSpeed(
normalizedPatch.animationSpeedMultiplier,
),
}),
},
{
onSuccess: () => {
setLastSyncedAt(Date.now());
pendingSyncRef.current = null;
setIsUpdating(false);
},
onError: () => {
setIsUpdating(false);
},
},
);
} else {
setIsUpdating(false);
}
},
[
prefersReducedMotion,
animationSpeedMultiplier,
autoSync,
updateMutation,
serverPrefs,
],
);
useEffect(() => {
if (!isHydratedRef.current) return;
if (serverHydratedRef.current) return;
if (!serverPrefs) return;
const localIsDefault =
prefersReducedMotion === DEFAULT_PREFERS_REDUCED &&
animationSpeedMultiplier === DEFAULT_SPEED;
const differs =
serverPrefs.prefersReducedMotion !== prefersReducedMotion ||
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect
performUpdate(
{
prefersReducedMotion: serverPrefs.prefersReducedMotion,
animationSpeedMultiplier: serverPrefs.animationSpeedMultiplier,
},
{ sync: false },
);
}
serverHydratedRef.current = true;
}, [
serverPrefs,
performUpdate,
prefersReducedMotion,
animationSpeedMultiplier,
]);
const updatePreferences = useCallback<
AnimationPreferencesContextValue["updatePreferences"]
>(
(patch, opts) => {
performUpdate(patch, opts);
},
[performUpdate],
);
const handleSetReduced = useCallback(
(val: boolean) => {
updatePreferences({ prefersReducedMotion: val });
},
[updatePreferences],
);
const handleSetSpeed = useCallback(
(val: number) => {
updatePreferences({ animationSpeedMultiplier: clampSpeed(val) });
},
[updatePreferences],
);
const value: AnimationPreferencesContextValue = {
prefersReducedMotion,
animationSpeedMultiplier,
updatePreferences,
setPrefersReducedMotion: handleSetReduced,
setAnimationSpeedMultiplier: handleSetSpeed,
isUpdating: isUpdating || updateMutation.isPending,
lastSyncedAt,
};
return (
<AnimationPreferencesContext.Provider value={value}>
{children}
</AnimationPreferencesContext.Provider>
);
}
@@ -1,58 +1,12 @@
"use client";
/**
* AnimationPreferencesProvider
*
* Centralized manager for user animation / motion preferences:
* - prefersReducedMotion (boolean)
* - animationSpeedMultiplier (0.25x 4x)
*
* Responsibilities:
* 1. Hydrate from (priority):
* - Inline early localStorage value (if already written by an inline script in layout)
* - Existing localStorage value
* - Server value (tRPC - user profile)
* - Initial props (e.g. server-fetched)
* - System media query (prefers-reduced-motion)
* 2. Apply preferences to:
* - documentElement class list (adds / removes .user-reduce-motion)
* - CSS custom properties: --animation-speed-fast/normal/slow
* 3. Persist to localStorage
* 4. Sync to server via tRPC mutation (debounced & resilient)
*
* Usage:
* <AnimationPreferencesProvider
* initial={{
* prefersReducedMotion: serverValue.prefersReducedMotion,
* animationSpeedMultiplier: serverValue.animationSpeedMultiplier
* }}
* >
* <App />
* </AnimationPreferencesProvider>
*
* const {
* prefersReducedMotion,
* animationSpeedMultiplier,
* updatePreferences,
* isUpdating
* } = useAnimationPreferences();
*
* updatePreferences({ animationSpeedMultiplier: 1.5 });
*
* NOTE: After integrating this provider, remove the duplicated logic
* from the settings page (SettingsContent) and call updatePreferences()
* instead of directly manipulating DOM / CSS variables there.
*/
import React, {
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { api } from "~/trpc/react";
type AnimationPreferences = {
prefersReducedMotion: boolean;
@@ -61,7 +15,7 @@ type AnimationPreferences = {
type PartialPrefs = Partial<AnimationPreferences>;
interface AnimationPreferencesContextValue extends AnimationPreferences {
export interface AnimationPreferencesContextValue extends AnimationPreferences {
updatePreferences: (patch: PartialPrefs, opts?: { sync?: boolean }) => void;
setPrefersReducedMotion: (val: boolean) => void;
setAnimationSpeedMultiplier: (val: number) => void;
@@ -69,33 +23,26 @@ interface AnimationPreferencesContextValue extends AnimationPreferences {
lastSyncedAt: number | null;
}
interface AnimationPreferencesProviderProps {
export interface AnimationPreferencesProviderProps {
children: React.ReactNode;
/**
* Optional initial values (e.g. from server / tRPC prefetch).
*/
initial?: PartialPrefs;
/**
* Disable auto-sync to server (mostly for test environments).
*/
autoSync?: boolean;
}
const STORAGE_KEY = "bv.animation.prefs";
const MIN_SPEED = 0.25;
const MAX_SPEED = 4;
const DEFAULT_SPEED = 1;
const DEFAULT_PREFERS_REDUCED = false;
export const STORAGE_KEY = "bv.animation.prefs";
export const MIN_SPEED = 0.25;
export const MAX_SPEED = 4;
export const DEFAULT_SPEED = 1;
export const DEFAULT_PREFERS_REDUCED = false;
const AnimationPreferencesContext =
export const AnimationPreferencesContext =
createContext<AnimationPreferencesContextValue | null>(null);
function clampSpeed(value: number): number {
export function clampSpeed(value: number): number {
if (Number.isNaN(value)) return DEFAULT_SPEED;
return Math.min(MAX_SPEED, Math.max(MIN_SPEED, value));
}
function readLocalStorage(): PartialPrefs | null {
export function readLocalStorage(): PartialPrefs | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
@@ -122,7 +69,7 @@ function readLocalStorage(): PartialPrefs | null {
}
}
function writeLocalStorage(prefs: AnimationPreferences) {
export function writeLocalStorage(prefs: AnimationPreferences) {
try {
localStorage.setItem(
STORAGE_KEY,
@@ -136,18 +83,16 @@ function writeLocalStorage(prefs: AnimationPreferences) {
}
}
function applyPreferencesToDOM(prefs: AnimationPreferences) {
export function applyPreferencesToDOM(prefs: AnimationPreferences) {
if (typeof document === "undefined") return;
const root = document.documentElement;
// Class toggle
if (prefs.prefersReducedMotion) {
root.classList.add("user-reduce-motion");
} else {
root.classList.remove("user-reduce-motion");
}
// Derive effective speeds
const multiplier = prefs.animationSpeedMultiplier || 1;
const fast = prefs.prefersReducedMotion
@@ -165,28 +110,11 @@ function applyPreferencesToDOM(prefs: AnimationPreferences) {
root.style.setProperty("--animation-speed-slow", `${slow}s`);
}
/**
* Provider component
*/
/** Local-only animation preferences for marketing and auth pages (no tRPC). */
export function AnimationPreferencesProvider({
children,
initial,
autoSync = true,
}: AnimationPreferencesProviderProps) {
const updateMutation = api.settings.updateAnimationPreferences.useMutation();
// Server query - tRPC will handle authentication internally
// The query will only succeed if the user is authenticated
const { data: serverPrefs } = api.settings.getAnimationPreferences.useQuery(
undefined,
{
enabled: true, // Let tRPC handle auth
refetchOnWindowFocus: false,
staleTime: 60_000,
retry: false, // Don't retry if not authenticated
},
);
const [prefersReducedMotion, setPrefersReducedMotion] = useState<boolean>(
initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED,
);
@@ -194,17 +122,10 @@ export function AnimationPreferencesProvider({
useState<number>(
clampSpeed(initial?.animationSpeedMultiplier ?? DEFAULT_SPEED),
);
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
const pendingSyncRef = useRef<PartialPrefs | null>(null);
const isHydratedRef = useRef(false);
const serverHydratedRef = useRef(false);
const [isUpdating, setIsUpdating] = useState(false);
// Hydration: run once on mount (local + system + initial)
useEffect(() => {
if (typeof window === "undefined") return;
const stored = readLocalStorage();
const systemReduced = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches;
@@ -220,171 +141,47 @@ export function AnimationPreferencesProvider({
DEFAULT_SPEED,
);
// eslint-disable-next-line react-hooks/set-state-in-effect -- Hydrate preferences from localStorage/system settings on mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPrefersReducedMotion(finalPrefers);
setAnimationSpeedMultiplier(finalSpeed);
applyPreferencesToDOM({
prefersReducedMotion: finalPrefers,
animationSpeedMultiplier: finalSpeed,
});
isHydratedRef.current = true;
}, [initial?.prefersReducedMotion, initial?.animationSpeedMultiplier]);
/**
* Core updater
*/
const performUpdate = useCallback(
(patch: PartialPrefs, opts?: { sync?: boolean }) => {
setIsUpdating(true);
setPrefersReducedMotion((prev) => patch.prefersReducedMotion ?? prev);
setAnimationSpeedMultiplier((prev) =>
clampSpeed(patch.animationSpeedMultiplier ?? prev),
);
// Normalize patch (avoid mutating the original function argument directly)
const normalizedPatch: PartialPrefs = { ...patch };
// If user enables reduced motion, force the animation speed multiplier to 1x (unless already specified)
if (
normalizedPatch.prefersReducedMotion === true &&
normalizedPatch.animationSpeedMultiplier === undefined &&
animationSpeedMultiplier !== 1
) {
normalizedPatch.animationSpeedMultiplier = 1;
}
const nextReduced =
normalizedPatch.prefersReducedMotion ?? prefersReducedMotion;
let nextSpeed = clampSpeed(
normalizedPatch.animationSpeedMultiplier ?? animationSpeedMultiplier,
);
// Enforce 1x when reduced motion is active
if (nextReduced && nextSpeed !== 1) {
nextSpeed = 1;
normalizedPatch.animationSpeedMultiplier ??= 1;
}
const newPrefs: AnimationPreferences = {
prefersReducedMotion: nextReduced,
animationSpeedMultiplier: nextSpeed,
};
// Apply to DOM immediately
applyPreferencesToDOM(newPrefs);
// Persist locally
writeLocalStorage(newPrefs);
// Optionally sync to server
const shouldSync = opts?.sync ?? autoSync;
if (shouldSync && serverPrefs) {
// If serverPrefs exists, user is authenticated
pendingSyncRef.current = {
prefersReducedMotion: patch.prefersReducedMotion,
animationSpeedMultiplier: patch.animationSpeedMultiplier,
};
updateMutation.mutate(
{
...(normalizedPatch.prefersReducedMotion !== undefined && {
prefersReducedMotion: normalizedPatch.prefersReducedMotion,
}),
...(normalizedPatch.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: clampSpeed(
normalizedPatch.animationSpeedMultiplier,
),
}),
},
{
onSuccess: () => {
setLastSyncedAt(Date.now());
pendingSyncRef.current = null;
setIsUpdating(false);
},
onError: () => {
setIsUpdating(false);
},
},
);
} else {
setIsUpdating(false);
}
},
[
prefersReducedMotion,
animationSpeedMultiplier,
autoSync,
updateMutation,
serverPrefs,
],
);
// Secondary hydration: apply server values if they differ AND user hasn't customized locally yet.
useEffect(() => {
if (!isHydratedRef.current) return;
if (serverHydratedRef.current) return;
if (!serverPrefs) return; // No server prefs means not authenticated or not loaded yet
const localIsDefault =
prefersReducedMotion === DEFAULT_PREFERS_REDUCED &&
animationSpeedMultiplier === DEFAULT_SPEED;
const differs =
serverPrefs.prefersReducedMotion !== prefersReducedMotion ||
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reconcile loaded server preferences once after query hydration.
performUpdate(
{
prefersReducedMotion: serverPrefs.prefersReducedMotion,
animationSpeedMultiplier: serverPrefs.animationSpeedMultiplier,
},
{ sync: false }, // Do not echo immediately back to server
);
}
serverHydratedRef.current = true;
}, [
serverPrefs,
performUpdate,
prefersReducedMotion,
animationSpeedMultiplier,
]);
const updatePreferences = useCallback<
AnimationPreferencesContextValue["updatePreferences"]
>(
(patch, opts) => {
performUpdate(patch, opts);
},
[performUpdate],
);
>((patch) => {
const nextReduced = patch.prefersReducedMotion ?? prefersReducedMotion;
let nextSpeed = clampSpeed(
patch.animationSpeedMultiplier ?? animationSpeedMultiplier,
);
if (nextReduced && nextSpeed !== 1) {
nextSpeed = 1;
}
// Dedicated setters (they sync by default)
const handleSetReduced = useCallback(
(val: boolean) => {
updatePreferences({ prefersReducedMotion: val });
},
[updatePreferences],
);
const handleSetSpeed = useCallback(
(val: number) => {
updatePreferences({ animationSpeedMultiplier: clampSpeed(val) });
},
[updatePreferences],
);
setPrefersReducedMotion(nextReduced);
setAnimationSpeedMultiplier(nextSpeed);
applyPreferencesToDOM({
prefersReducedMotion: nextReduced,
animationSpeedMultiplier: nextSpeed,
});
writeLocalStorage({
prefersReducedMotion: nextReduced,
animationSpeedMultiplier: nextSpeed,
});
}, [prefersReducedMotion, animationSpeedMultiplier]);
const value: AnimationPreferencesContextValue = {
prefersReducedMotion,
animationSpeedMultiplier,
updatePreferences,
setPrefersReducedMotion: handleSetReduced,
setAnimationSpeedMultiplier: handleSetSpeed,
isUpdating: isUpdating || updateMutation.isPending,
lastSyncedAt,
setPrefersReducedMotion: (val) => updatePreferences({ prefersReducedMotion: val }),
setAnimationSpeedMultiplier: (val) =>
updatePreferences({ animationSpeedMultiplier: clampSpeed(val) }),
isUpdating: false,
lastSyncedAt: null,
};
return (
@@ -394,13 +191,9 @@ export function AnimationPreferencesProvider({
);
}
/**
* Hook consumer
*/
export function useAnimationPreferences(): AnimationPreferencesContextValue {
const ctx = useContext(AnimationPreferencesContext);
if (!ctx) {
// Fallback instead of throwing to prevent runtime crashes if provider is missing
console.warn("useAnimationPreferences used without provider");
return {
prefersReducedMotion: false,
@@ -421,16 +214,6 @@ export function useAnimationPreferences(): AnimationPreferencesContextValue {
return ctx;
}
/**
* Helper: Inline script snippet (for layout) to minimize FOUC.
* (Not executed here—copy the string contents into a <script dangerouslySetInnerHTML={{__html: ...}} /> early in <head>.)
*
* Example usage in layout.tsx (before loading CSS-heavy content):
*
* <script
* dangerouslySetInnerHTML={{ __html: getInlineAnimationPrefsScript() }}
* />
*/
export function getInlineAnimationPrefsScript(): string {
return `
(function(){
@@ -445,7 +228,6 @@ export function getInlineAnimationPrefsScript(): string {
if (typeof parsed.prefersReducedMotion === 'boolean') {
prefersReduced = parsed.prefersReducedMotion;
} else {
// fallback to system preference if available
prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
if (typeof parsed.animationSpeedMultiplier === 'number') {
@@ -0,0 +1,18 @@
"use client";
import { AnimationPreferencesProviderSynced } from "~/components/providers/animation-preferences-provider-synced";
import { AppearanceProviderSynced } from "~/components/providers/appearance-provider-synced";
import { TRPCReactProvider } from "~/trpc/react";
/** Full app providers for authenticated workspace routes. */
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<TRPCReactProvider>
<AppearanceProviderSynced>
<AnimationPreferencesProviderSynced>
{children}
</AnimationPreferencesProviderSynced>
</AppearanceProviderSynced>
</TRPCReactProvider>
);
}
@@ -0,0 +1,237 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isHslChannels } from "~/lib/appearance";
import type { ColorMode, ColorTheme, FontPreference, InterfaceTheme, RadiusPreference, SidebarStyle } from "~/lib/branding";
import { api } from "~/trpc/react";
import {
AppearanceContext,
applyAppearance,
defaultAppearance,
readStoredAppearance,
writeStoredAppearance,
type AppearanceContextValue,
type AppearancePatch,
type AppearancePreferences,
} from "~/components/providers/appearance-provider";
type ServerAppearance = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
theme: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: AppearancePreferences["pdfTemplate"];
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
function getServerAppearancePatch(
serverAppearance: ServerAppearance,
): AppearancePatch {
return {
interfaceTheme: serverAppearance.interfaceTheme,
bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference,
sidebarStyle: serverAppearance.sidebarStyle,
colorMode: serverAppearance.theme,
colorTheme: serverAppearance.colorTheme,
customColor: serverAppearance.customColor,
brandName: serverAppearance.brandName,
brandTagline: serverAppearance.brandTagline,
brandLogoText: serverAppearance.brandLogoText,
brandIcon: serverAppearance.brandIcon,
pdfTemplate: serverAppearance.pdfTemplate,
pdfAccentColor: serverAppearance.pdfAccentColor,
pdfFooterText: serverAppearance.pdfFooterText,
pdfShowLogo: serverAppearance.pdfShowLogo,
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
};
}
/** Dashboard appearance provider with tRPC theme sync. Must render inside TRPCReactProvider. */
export function AppearanceProviderSynced({
children,
}: {
children: React.ReactNode;
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => {
await utils.settings.getTheme.invalidate();
},
onError: () => {
const cachedAppearance = utils.settings.getTheme.getData();
const fallback = cachedAppearance
? {
...defaultAppearance,
...getServerAppearancePatch(cachedAppearance),
}
: defaultAppearance;
setAppearance(fallback);
applyAppearance(fallback);
writeStoredAppearance(fallback);
},
});
const persistAppearance = useCallback(
(patch: AppearancePatch) => {
if (
patch.customColor !== undefined &&
!isHslChannels(patch.customColor)
) {
return;
}
updateMutation.mutate({
interfaceTheme: patch.interfaceTheme,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
},
[updateMutation],
);
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
useEffect(() => {
const storedAppearance = readStoredAppearance();
if (!storedAppearance) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
}, []);
useEffect(() => {
if (!serverAppearance) return;
const next = getServerAppearancePatch(serverAppearance);
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...next }));
}, [serverAppearance]);
useEffect(() => {
applyAppearance(appearance);
writeStoredAppearance(appearance);
}, [appearance]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
}
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
persistAppearance(patch);
},
[persistAppearance],
);
const updateAppearanceDebounced = useCallback(
(patch: AppearancePatch) => {
pendingDebouncedPatchRef.current = {
...pendingDebouncedPatchRef.current,
...patch,
};
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
debounceTimerRef.current = null;
}, 500);
},
[persistAppearance],
);
useEffect(
() => () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
pendingDebouncedPatchRef.current = {};
},
[],
);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
updateAppearance,
updateAppearanceDebounced,
isUpdating: updateMutation.isPending,
}),
[
appearance,
updateAppearance,
updateAppearanceDebounced,
updateMutation.isPending,
],
);
return (
<AppearanceContext.Provider value={value}>
{children}
</AppearanceContext.Provider>
);
}
+29 -187
View File
@@ -6,7 +6,6 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
@@ -35,9 +34,8 @@ import {
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
import { api } from "~/trpc/react";
type AppearancePreferences = {
export type AppearancePreferences = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
@@ -57,37 +55,17 @@ type AppearancePreferences = {
pdfShowPageNumbers: boolean;
};
type AppearancePatch = Partial<AppearancePreferences>;
export type AppearancePatch = Partial<AppearancePreferences>;
type ServerAppearance = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
theme: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
type AppearanceContextValue = AppearancePreferences & {
export type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void;
updateAppearanceDebounced: (patch: AppearancePatch) => void;
isUpdating: boolean;
};
const STORAGE_KEY = "bv.appearance";
export const STORAGE_KEY = "bv.appearance";
const defaultAppearance: AppearancePreferences = {
export const defaultAppearance: AppearancePreferences = {
interfaceTheme: defaultInterfaceTheme,
bodyFontPreference: defaultBodyFontPreference,
headingFontPreference: defaultHeadingFontPreference,
@@ -106,33 +84,10 @@ const defaultAppearance: AppearancePreferences = {
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
};
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
export const AppearanceContext =
createContext<AppearanceContextValue | null>(null);
function getServerAppearancePatch(
serverAppearance: ServerAppearance,
): AppearancePatch {
return {
interfaceTheme: serverAppearance.interfaceTheme,
bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference,
sidebarStyle: serverAppearance.sidebarStyle,
colorMode: serverAppearance.theme,
colorTheme: serverAppearance.colorTheme,
customColor: serverAppearance.customColor,
brandName: serverAppearance.brandName,
brandTagline: serverAppearance.brandTagline,
brandLogoText: serverAppearance.brandLogoText,
brandIcon: serverAppearance.brandIcon,
pdfTemplate: serverAppearance.pdfTemplate,
pdfAccentColor: serverAppearance.pdfAccentColor,
pdfFooterText: serverAppearance.pdfFooterText,
pdfShowLogo: serverAppearance.pdfShowLogo,
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
};
}
function readStoredAppearance(): Partial<AppearancePreferences> | null {
export function readStoredAppearance(): Partial<AppearancePreferences> | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
@@ -201,7 +156,7 @@ function readStoredAppearance(): Partial<AppearancePreferences> | null {
}
}
function writeStoredAppearance(prefs: AppearancePreferences) {
export function writeStoredAppearance(prefs: AppearancePreferences) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
@@ -209,7 +164,7 @@ function writeStoredAppearance(prefs: AppearancePreferences) {
}
}
function applyAppearance(prefs: AppearancePreferences) {
export function applyAppearance(prefs: AppearancePreferences) {
if (typeof document === "undefined") return;
const root = document.documentElement;
@@ -230,6 +185,7 @@ function applyAppearance(prefs: AppearancePreferences) {
}
}
/** Local-only appearance provider for marketing and auth pages (no tRPC). */
export function AppearanceProvider({
children,
}: {
@@ -237,65 +193,6 @@ export function AppearanceProvider({
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => {
await utils.settings.getTheme.invalidate();
},
onError: () => {
const cachedAppearance = utils.settings.getTheme.getData();
const fallback = cachedAppearance
? {
...defaultAppearance,
...getServerAppearancePatch(cachedAppearance),
}
: defaultAppearance;
setAppearance(fallback);
applyAppearance(fallback);
writeStoredAppearance(fallback);
},
});
const persistAppearance = useCallback(
(patch: AppearancePatch) => {
if (
patch.customColor !== undefined &&
!isHslChannels(patch.customColor)
) {
return;
}
updateMutation.mutate({
interfaceTheme: patch.interfaceTheme,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
},
[updateMutation],
);
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
useEffect(() => {
const storedAppearance = readStoredAppearance();
@@ -305,92 +202,37 @@ export function AppearanceProvider({
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
}, []);
useEffect(() => {
if (!serverAppearance) return;
const next = getServerAppearancePatch(serverAppearance);
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...next }));
}, [serverAppearance]);
useEffect(() => {
applyAppearance(appearance);
writeStoredAppearance(appearance);
}, [appearance]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
}
const updateAppearance = useCallback((patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
}, []);
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
persistAppearance(patch);
},
[persistAppearance],
);
const updateAppearanceDebounced = useCallback(
(patch: AppearancePatch) => {
pendingDebouncedPatchRef.current = {
...pendingDebouncedPatchRef.current,
...patch,
};
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
debounceTimerRef.current = null;
}, 500);
},
[persistAppearance],
);
useEffect(
() => () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
pendingDebouncedPatchRef.current = {};
},
[],
);
const updateAppearanceDebounced = useCallback((patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
}, []);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
updateAppearance,
updateAppearanceDebounced,
isUpdating: updateMutation.isPending,
isUpdating: false,
}),
[
appearance,
updateAppearance,
updateAppearanceDebounced,
updateMutation.isPending,
],
[appearance, updateAppearance, updateAppearanceDebounced],
);
return (
@@ -0,0 +1,17 @@
"use client";
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
import { AppearanceProvider } from "~/components/providers/appearance-provider";
/** Client providers for public/marketing pages — no tRPC or DB-backed settings sync. */
export function MarketingProviders({
children,
}: {
children: React.ReactNode;
}) {
return (
<AppearanceProvider>
<AnimationPreferencesProvider>{children}</AnimationPreferencesProvider>
</AppearanceProvider>
);
}