Archived
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user