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:
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||
import { brand } from "~/lib/branding";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -14,5 +15,5 @@ export default function LegalLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
return <MarketingProviders>{children}</MarketingProviders>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||
|
||||
export default function MarketingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <MarketingProviders>{children}</MarketingProviders>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { LandingPage } from "~/components/marketing/landing-page";
|
||||
import { env } from "~/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function HomePage() {
|
||||
const allowRegistration = env.DISABLE_SIGNUPS !== true;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<LandingPage allowRegistration={allowRegistration} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <MarketingProviders>{children}</MarketingProviders>;
|
||||
}
|
||||
@@ -4,7 +4,13 @@ import { SignInForm } from "./signin-form";
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="bg-dashboard text-muted-foreground flex min-h-screen items-center justify-center text-sm">
|
||||
Loading…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignInForm allowRegistration={env.DISABLE_SIGNUPS !== true} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
+116
-130
@@ -1,17 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Suspense } from "react";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { ArrowRight, Lock, Mail, Shield } from "lucide-react";
|
||||
import {
|
||||
AuthCard,
|
||||
AuthCardHeader,
|
||||
AuthPageShell,
|
||||
} from "~/components/auth/auth-page-shell";
|
||||
import { LegalAgreementNotice } from "~/components/legal/legal-links";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { env } from "~/env";
|
||||
import { Mail, Lock, ArrowRight, Shield } from "lucide-react";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface SignInFormProps {
|
||||
allowRegistration: boolean;
|
||||
@@ -41,11 +45,12 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
||||
? error.message
|
||||
: "Invalid email or password",
|
||||
);
|
||||
} else {
|
||||
toast.success("Signed in successfully!");
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Signed in successfully!");
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleSocialSignIn() {
|
||||
@@ -62,127 +67,108 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
|
||||
<div className="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>
|
||||
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
|
||||
</div>
|
||||
<AuthPageShell>
|
||||
<AuthCard>
|
||||
<AuthCardHeader
|
||||
title="Welcome back"
|
||||
description="Sign in to your workspace"
|
||||
/>
|
||||
|
||||
<Card className="mx-auto w-full max-w-md border-border/50 bg-background/80 backdrop-blur-xl">
|
||||
<CardContent className="p-8">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Logo size="lg" />
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-bold">Welcome back</h1>
|
||||
<p className="text-muted-foreground text-sm">Sign in to your account</p>
|
||||
{signupDisabled && (
|
||||
<p className="bg-muted/50 text-muted-foreground mb-5 rounded-xl border px-3 py-2.5 text-sm">
|
||||
New account registration is currently disabled.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{authentikEnabled && (
|
||||
<div className="mb-5 space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="h-11 w-full"
|
||||
onClick={handleSocialSignIn}
|
||||
disabled={loading}
|
||||
>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
Sign in with Authentik
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="border-border/50 w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background/80 text-muted-foreground px-2">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{signupDisabled && (
|
||||
<div className="border-border bg-muted/50 text-muted-foreground rounded-lg border px-3 py-2 text-sm">
|
||||
New account registration is currently disabled.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authentikEnabled && (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="h-10 w-full"
|
||||
onClick={handleSocialSignIn}
|
||||
disabled={loading}
|
||||
>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
Sign in with Authentik
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="border-border/50 w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background text-muted-foreground px-2">or</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSignIn} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="h-10 pl-10"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<a href="/auth/forgot-password" className="text-muted-foreground text-xs hover:underline">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="h-10 pl-10"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="h-10 w-full" disabled={loading}>
|
||||
{loading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2" />
|
||||
<span>Signing in…</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Sign In</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{allowRegistration && (
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<a href="/auth/register" className="text-foreground font-medium hover:underline">
|
||||
Sign up
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<LegalAgreementNotice action="signing in" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
|
||||
export default function SignInPageClient() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<SignInForm allowRegistration />
|
||||
</Suspense>
|
||||
<form onSubmit={handleSignIn} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="email"
|
||||
className="h-11 pl-10"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
className="text-muted-foreground text-xs hover:text-foreground hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="h-11 pl-10"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="h-11 w-full" disabled={loading}>
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
{!loading && <ArrowRight className="ml-2 h-4 w-4" />}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{allowRegistration && !signupDisabled && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="text-foreground font-medium hover:underline"
|
||||
>
|
||||
Create account
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<LegalAgreementNotice action="signing in" className="mt-5" />
|
||||
</AuthCard>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AppProviders } from "~/components/providers/app-providers";
|
||||
import { DashboardShell } from "~/components/layout/dashboard-shell";
|
||||
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function DashboardLayout({
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <DashboardShell>{children}</DashboardShell>;
|
||||
const session = await getOptionalServerSessionFromHeaders();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/auth/signin?callbackUrl=/dashboard");
|
||||
}
|
||||
|
||||
return (
|
||||
<AppProviders>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@ import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import { auth } from "~/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||
import { HydrateClient, api } from "~/trpc/server";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
import { RevenueChart, InvoiceStatusChart, MonthlyMetricsChart } from "~/app/dashboard/_components/charts-client";
|
||||
@@ -494,9 +493,7 @@ import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
// ... imports
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
const session = await getOptionalServerSessionFromHeaders();
|
||||
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
||||
|
||||
// Fetch stats centrally
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
|
||||
/** Public invoice links need tRPC but not authenticated settings sync. */
|
||||
export default function PublicInvoiceLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TRPCReactProvider>
|
||||
<MarketingProviders>{children}</MarketingProviders>
|
||||
</TRPCReactProvider>
|
||||
);
|
||||
}
|
||||
+5
-16
@@ -3,10 +3,7 @@ import "~/styles/globals.css";
|
||||
import { type Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
|
||||
import { AppearanceProvider } from "~/components/providers/appearance-provider";
|
||||
import {
|
||||
brand,
|
||||
defaultBodyFontPreference,
|
||||
@@ -17,6 +14,7 @@ import {
|
||||
} from "~/lib/branding";
|
||||
|
||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||
import { BrandBackground } from "~/components/layout/brand-background";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `${brand.name} - Invoicing Made Simple`,
|
||||
@@ -108,20 +106,11 @@ export default function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||
<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>
|
||||
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30"></div>
|
||||
</div>
|
||||
<BrandBackground />
|
||||
|
||||
<TRPCReactProvider>
|
||||
<AppearanceProvider>
|
||||
<AnimationPreferencesProvider>
|
||||
<div className="relative z-10">{children}</div>
|
||||
</AnimationPreferencesProvider>
|
||||
</AppearanceProvider>
|
||||
<Toaster />
|
||||
<UmamiScript />
|
||||
</TRPCReactProvider>
|
||||
<div className="relative z-10">{children}</div>
|
||||
<Toaster />
|
||||
<UmamiScript />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, FileText, UserRound } from "lucide-react";
|
||||
import { AuthRedirect } from "~/components/AuthRedirect";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { env } from "~/env";
|
||||
import { brand } from "~/lib/branding";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function HomePage() {
|
||||
const allowRegistration = env.DISABLE_SIGNUPS !== true;
|
||||
|
||||
return (
|
||||
<main className="bg-background text-foreground min-h-screen">
|
||||
<AuthRedirect />
|
||||
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-5 py-5 sm:px-6 lg:px-8">
|
||||
<header className="flex items-center justify-between gap-4 border-b py-4">
|
||||
<Logo animated={false} />
|
||||
<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>
|
||||
|
||||
<section className="grid flex-1 items-center gap-10 py-14 md:grid-cols-[1fr_320px] md:py-20">
|
||||
<div className="max-w-2xl space-y-7">
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Personal invoicing
|
||||
</p>
|
||||
<h1 className="font-heading text-4xl leading-tight font-bold tracking-normal sm:text-5xl">
|
||||
{brand.name} is a place to make and track invoices.
|
||||
</h1>
|
||||
<p className="text-muted-foreground max-w-xl text-base leading-7 sm:text-lg">
|
||||
Built for one person managing real clients, real work, and the
|
||||
small admin loop around getting paid.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Link href="/auth/signin">
|
||||
<Button size="lg" className="h-11 px-5">
|
||||
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-5">
|
||||
Create account
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border bg-card text-card-foreground rounded-xl border p-5 shadow-sm">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 text-primary rounded-md p-2">
|
||||
<UserRound className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Clients</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm leading-6">
|
||||
Keep the people and businesses you invoice in one place.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 border-t pt-5">
|
||||
<div className="bg-primary/10 text-primary rounded-md p-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Invoices</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm leading-6">
|
||||
Draft, send, mark paid, and export the PDF when you need it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="text-muted-foreground flex flex-col gap-3 border-t py-5 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>© 2026 {brand.name}</span>
|
||||
<div className="flex gap-5">
|
||||
<Link href="/privacy" className="hover:text-foreground">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-foreground">
|
||||
Terms of Service
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+11
-4
@@ -3,10 +3,17 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||
|
||||
/**
|
||||
* Auth client configuration
|
||||
*/
|
||||
function resolveAuthBaseUrl(): string | undefined {
|
||||
// Always use the current origin in the browser so dev works on any port
|
||||
// (e.g. 3002 when 3000 is taken), without rebuilding for NEXT_PUBLIC_APP_URL.
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
return process.env.NEXT_PUBLIC_APP_URL;
|
||||
}
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL,
|
||||
baseURL: resolveAuthBaseUrl(),
|
||||
plugins: [genericOAuthClient()],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { auth } from "~/lib/auth";
|
||||
|
||||
export function hasSessionCookie(headers: Headers): boolean {
|
||||
const cookie = headers.get("cookie") ?? "";
|
||||
return (
|
||||
cookie.includes("better-auth.session_token=") ||
|
||||
cookie.includes("__Secure-better-auth.session_token=")
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOptionalServerSession(headers: Headers) {
|
||||
if (!hasSessionCookie(headers)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await auth.api.getSession({ headers });
|
||||
} catch (error) {
|
||||
console.error("[auth] Failed to resolve session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOptionalServerSessionFromHeaders() {
|
||||
return getOptionalServerSession(await nextHeaders());
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Routes that do not require authentication or server session lookups. */
|
||||
export const PUBLIC_ROUTES = [
|
||||
"/",
|
||||
"/auth/signin",
|
||||
"/auth/register",
|
||||
"/auth/forgot-password",
|
||||
"/auth/reset-password",
|
||||
"/privacy",
|
||||
"/terms",
|
||||
] as const;
|
||||
|
||||
/** Path prefixes treated as public (e.g. shareable invoice links). */
|
||||
export const PUBLIC_ROUTE_PREFIXES = ["/i/"] as const;
|
||||
|
||||
export function isPublicRoute(pathname: string): boolean {
|
||||
if ((PUBLIC_ROUTES as readonly string[]).includes(pathname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||
}
|
||||
+2
-10
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { envBoolean } from "~/lib/env-boolean";
|
||||
import { isPublicRoute } from "~/lib/public-routes";
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
@@ -11,15 +12,6 @@ export function proxy(request: NextRequest) {
|
||||
return NextResponse.redirect(signInUrl);
|
||||
}
|
||||
|
||||
// Define public routes that don't require authentication
|
||||
const publicRoutes = [
|
||||
"/",
|
||||
"/auth/signin",
|
||||
"/auth/register",
|
||||
"/privacy",
|
||||
"/terms",
|
||||
];
|
||||
|
||||
// Define API routes that should be handled separately
|
||||
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"];
|
||||
|
||||
@@ -29,7 +21,7 @@ export function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Allow public routes for everyone
|
||||
if (publicRoutes.includes(pathname)) {
|
||||
if (isPublicRoute(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
+33
-10
@@ -12,6 +12,7 @@ import superjson from "superjson";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { auth } from "~/lib/auth";
|
||||
import { hasSessionCookie } from "~/lib/auth-server";
|
||||
import { db } from "~/server/db";
|
||||
import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys";
|
||||
|
||||
@@ -47,17 +48,39 @@ export const createTRPCContext = async (opts: { headers: Headers }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: opts.headers,
|
||||
});
|
||||
if (!hasSessionCookie(opts.headers)) {
|
||||
return {
|
||||
db,
|
||||
session: null,
|
||||
authSource: "none" as const,
|
||||
apiKeyId: null,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
db,
|
||||
session,
|
||||
authSource: session?.user ? ("session" as const) : ("none" as const),
|
||||
apiKeyId: null,
|
||||
...opts,
|
||||
};
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: opts.headers,
|
||||
});
|
||||
|
||||
return {
|
||||
db,
|
||||
session,
|
||||
authSource: session?.user ? ("session" as const) : ("none" as const),
|
||||
apiKeyId: null,
|
||||
...opts,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[tRPC] Failed to resolve session:", error);
|
||||
|
||||
return {
|
||||
db,
|
||||
session: null,
|
||||
authSource: "none" as const,
|
||||
apiKeyId: null,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user