Redesign marketing pages and isolate public routes from app auth.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 02:00:06 -04:00
co-authored by Cursor
parent 82977b6dd8
commit 6ec26a4a0d
36 changed files with 1743 additions and 819 deletions
+124 -39
View File
@@ -1,68 +1,153 @@
# beenvoice-web environment
# =============================================================================
# beenvoice-web — environment template
# =============================================================================
#
# Local dev: cp .env.example .env.local
# Docker: cp .env.example .env
# Quick start (local dev):
# cp .env.example .env.local
# docker compose -f docker-compose.dev.yml up -d
# bun run db:push # or: bun run db:migrate
# bun run dev
#
# Docker deploy checklist:
# 1. Set AUTH_SECRET, BETTER_AUTH_URL, NEXT_PUBLIC_APP_URL to your public URL
# 2. docker compose build --no-cache app # NEXT_PUBLIC_* is baked in at build
# 3. docker compose up -d --build # migrations run on app container start
# Quick start (Docker app + Postgres):
# cp .env.example .env
# # edit AUTH_SECRET + public URLs below
# docker compose up -d --build
#
# Updating: git pull && docker compose up -d --build
# (git pull alone does not rebuild; up -d without --build keeps the old image)
# -----------------------------------------------------------------------------
# Build-time vs runtime (Docker)
# -----------------------------------------------------------------------------
#
# Migrations are idempotent — only pending SQL files are applied on each start.
# Baked into the image at `docker compose build` (rebuild after changes):
# NEXT_PUBLIC_APP_URL
# NEXT_PUBLIC_* branding / theme defaults
# NEXT_PUBLIC_AUTHENTIK_ENABLED
# NEXT_PUBLIC_UMAMI_*
#
# Read from .env when the container starts (restart app after changes):
# AUTH_SECRET, BETTER_AUTH_URL, DATABASE_URL (compose overrides host),
# RESEND_*, DISABLE_SIGNUPS, AUTHENTIK_* secrets, CRON_SECRET
#
# `NEXT_PUBLIC_APP_URL` should still match your public browser URL for SSR,
# emails, and MCP links. In the browser, sign-in uses the current page origin
# automatically so dev works when Next picks another port (e.g. 3002).
#
# Updating production: git pull && docker compose up -d --build
# Migrations run on every app start (idempotent — only pending SQL is applied).
# Runtime
NODE_ENV=production
WEB_PORT=3000
# =============================================================================
# Core — required
# =============================================================================
# Auth
# Generate with: openssl rand -base64 32
# PostgreSQL connection string.
# Local dev (docker-compose.dev.yml): host is localhost
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
# Session signing secret. Required in production.
# Generate: openssl rand -base64 32
AUTH_SECRET=change-me-generate-a-real-secret
# Must match the URL users open in the browser (include https:// and port if non-standard).
# Public URL users open in the browser (scheme + host + port if non-standard).
# Must match how you access the app for cookies, OAuth callbacks, and email links.
BETTER_AUTH_URL=http://localhost:3000
# Public app URL — baked into the client bundle at Docker build time.
# Set this to the same value as BETTER_AUTH_URL before `docker compose build`.
# Same as BETTER_AUTH_URL in most setups. Embedded in the client bundle at build.
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Postgres used by docker-compose.yml
# =============================================================================
# Local development
# =============================================================================
NODE_ENV=development
# Set true when connecting to local Postgres without SSL (default for compose).
DB_DISABLE_SSL=true
# Dev-only: host port for `docker compose -f docker-compose.dev.yml` Postgres.
POSTGRES_PORT=5432
# Optional: if Next dev picks another port, you do not need to change URLs for
# sign-in — the auth client uses window.location.origin in the browser.
# =============================================================================
# Docker Compose (app + database)
# =============================================================================
# Host port mapped to container :3000 (WEB_PORT, then PORT, then 3000).
WEB_PORT=3000
# Postgres credentials for docker-compose.yml `db` service.
# DATABASE_URL inside the app container is set by compose (host `db`, not localhost).
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=postgres
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
DB_DISABLE_SSL=true
# White-label defaults used at image build time.
# Admin-managed platform branding in the app can override these after setup.
NEXT_PUBLIC_BRAND_NAME="beenvoice"
NEXT_PUBLIC_BRAND_TAGLINE="Simple and efficient invoicing for freelancers and small businesses"
NEXT_PUBLIC_BRAND_LOGO_TEXT="beenvoice"
NEXT_PUBLIC_BRAND_ICON="$"
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME="beenvoice"
NEXT_PUBLIC_DEFAULT_FONT="brand"
NEXT_PUBLIC_DEFAULT_BODY_FONT="brand"
NEXT_PUBLIC_DEFAULT_HEADING_FONT="brand"
NEXT_PUBLIC_DEFAULT_RADIUS="xl"
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE="floating"
# =============================================================================
# White-label defaults (optional)
# =============================================================================
# Baked in at Docker build. After first deploy, admins can override many of
# these from Settings → Appearance in the dashboard.
NEXT_PUBLIC_BRAND_NAME=beenvoice
NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses
NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
NEXT_PUBLIC_BRAND_ICON=$
# Interface theme: beenvoice | frutiger | frutiger-aero | shadcn | minimal | editorial
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME=beenvoice
# Font prefs: brand | frutiger | platform | inter | serif
NEXT_PUBLIC_DEFAULT_FONT=brand
NEXT_PUBLIC_DEFAULT_BODY_FONT=brand
NEXT_PUBLIC_DEFAULT_HEADING_FONT=brand
# Corner radius: none | sm | md | lg | xl
NEXT_PUBLIC_DEFAULT_RADIUS=xl
# Sidebar chrome: floating | docked
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE=floating
# =============================================================================
# Email — Resend (optional)
# =============================================================================
# Leave blank to disable invoice and password-reset email delivery.
# Email delivery via Resend (optional)
# Leave blank to disable invoice/password-reset email delivery.
RESEND_API_KEY=
RESEND_DOMAIN=
# Analytics via Umami (optional)
# Leave website ID blank to disable analytics.
# =============================================================================
# Analytics — Umami (optional)
# =============================================================================
# Leave website ID blank to disable.
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
# Block new email/password registrations (optional)
# =============================================================================
# Access control (optional)
# =============================================================================
# Block new email/password registrations. Use literal true or false.
# DISABLE_SIGNUPS=true
# SSO via Authentik OIDC (optional)
# Bearer token for POST /api/cron/generate-recurring (recurring invoice cron).
# CRON_SECRET=
# =============================================================================
# SSO — Authentik OIDC (optional)
# =============================================================================
# Set NEXT_PUBLIC_AUTHENTIK_ENABLED=true and rebuild the image to show SSO on
# sign-in. Server secrets are runtime-only (no rebuild needed for secrets).
NEXT_PUBLIC_AUTHENTIK_ENABLED=false
AUTHENTIK_ISSUER=
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
# Optional extra trusted origin for better-auth (defaults derived from issuer).
AUTHENTIK_ORIGIN=
# =============================================================================
# Advanced / CI (usually unset)
# =============================================================================
# Skip Zod env validation during `next build` (set automatically in Dockerfile).
# SKIP_ENV_VALIDATION=1
+6 -5
View File
@@ -6,7 +6,10 @@ FROM base AS install
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
FROM base AS build
# Next.js build must run on Node — Bun 1.3.x can SIGSEGV on Linux arm64 during
# the "Collecting page data" worker phase (oven-sh/bun#...). Runtime stays on Bun.
FROM node:22-bookworm-slim AS build
WORKDIR /usr/src/app
COPY --from=install /usr/src/app/node_modules node_modules
COPY . .
@@ -15,19 +18,17 @@ ARG BETTER_AUTH_URL=http://localhost:3000
# Low-memory Docker build profile:
# - disable React Compiler (saves compile RAM; prod image still runs fine without it)
# - skip eslint/tsc inside `next build` (run `bun run check` in CI instead)
# - cap Node heap at 2GB (raise Docker/Colima memory if this still OOMs)
# - skip tsc inside `next build` (run `bun run check` in CI instead)
ENV DOCKER_BUILD=1 \
DISABLE_REACT_COMPILER=1 \
NODE_ENV=production \
SKIP_ENV_VALIDATION=1 \
NEXT_TELEMETRY_DISABLED=1 \
NODE_OPTIONS=--max-old-space-size=2048 \
BETTER_AUTH_URL=${BETTER_AUTH_URL} \
NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} \
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
RUN bun run build
RUN node ./node_modules/next/dist/bin/next build
FROM base AS release
ENV NODE_ENV=production \
+2 -2
View File
@@ -94,7 +94,7 @@ The production compose file runs the Next.js app and PostgreSQL.
**Container startup** runs `bun migrate.ts && bun run start` (see `Dockerfile`). Drizzle only applies **pending** migrations — safe to run on every restart; already-applied migrations are skipped.
The Docker **build** uses a low-memory profile (React Compiler off, webpack memory optimizations, 2GB Node heap cap). Give Docker/Colima at least **34GB RAM** for builds. If it still OOMs, raise VM memory (`colima start --memory 6`) — avoid `--no-cache` unless debugging.
The Docker **build** runs `next build` on **Node 22** (Bun can crash on Linux arm64 during the page-data worker phase). The **runtime** image still uses Bun for migrations and `next start`. `docker-compose.yml` does not set container memory or CPU limits — containers can use whatever the Docker host provides.
### 1. Configure
@@ -128,7 +128,7 @@ docker compose up -d --build
`--build` is important. A plain `docker compose up -d` reuses the existing image and **does not** pick up new code from `git pull`.
App listens on `${WEB_PORT:-3000}`. Postgres stays on the internal compose network.
App listens on `${WEB_PORT:-${PORT:-3000}}` on the host (container port is always 3000). Postgres stays on the internal compose network.
### 3. Updating an existing deploy
+1 -1
View File
@@ -24,7 +24,7 @@ services:
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
ports:
- "${WEB_PORT:-3000}:3000"
- "${WEB_PORT:-${PORT:-3000}}:3000"
depends_on:
db:
condition: service_healthy
+1 -1
View File
@@ -186,7 +186,7 @@ Validated in `src/env.js`. See `.env.example`.
| `docker-compose.yml` | Deploy: `app` + `db` (Postgres internal); copy `.env.example``.env` |
| `docker-compose.dev.yml` | Local dev: Postgres only, port `${POSTGRES_PORT:-5432}` |
App image built from `Dockerfile`. Container `CMD`: `bun migrate.ts && bun run start` (migrations then `next start` on port 3000). Docker builds disable React Compiler and use `experimental.webpackMemoryOptimizations` to reduce peak RAM.
App image built from `Dockerfile`. Container `CMD`: `bun migrate.ts && bun run start` (migrations then `next start` on port 3000). Docker builds run `next build` on Node 22 (not Bun) to avoid arm64 worker crashes; runtime stays on Bun. Docker builds disable React Compiler and use `experimental.webpackMemoryOptimizations` to reduce peak RAM.
Set `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` to the public hostname before deploy. Rebuild the image when changing `NEXT_PUBLIC_*` build-time vars.
+2 -2
View File
@@ -16,11 +16,11 @@ const config = {
experimental: {
webpackMemoryOptimizations: true,
},
// Skip duplicate typecheck/eslint during Docker `next build` to lower peak memory.
// Skip duplicate typecheck during Docker `next build` to lower peak memory.
// Lint separately via `bun run lint` / `bun run check`.
...(isDockerBuild
? {
typescript: { ignoreBuildErrors: true },
eslint: { ignoreDuringBuilds: true },
}
: {}),
};
+2 -1
View File
@@ -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>;
}
+9
View File
@@ -0,0 +1,9 @@
import { MarketingProviders } from "~/components/providers/marketing-providers";
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return <MarketingProviders>{children}</MarketingProviders>;
}
+14
View File
@@ -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>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { MarketingProviders } from "~/components/providers/marketing-providers";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return <MarketingProviders>{children}</MarketingProviders>;
}
+7 -1
View File
@@ -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
View File
@@ -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&apos;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&apos;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>
);
}
+15 -2
View File
@@ -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>
);
}
+2 -5
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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>
);
-110
View File
@@ -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>
);
}
+71
View File
@@ -0,0 +1,71 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Logo } from "~/components/branding/logo";
import { cn } from "~/lib/utils";
export function AuthPageShell({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="bg-dashboard text-foreground flex min-h-screen flex-col px-5 py-6 sm:px-6 sm:py-8">
<div
className={cn(
"mx-auto flex w-full max-w-md flex-1 flex-col justify-center",
className,
)}
>
<Link
href="/"
className="text-muted-foreground hover:text-foreground mb-6 inline-flex items-center gap-2 text-sm transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to home
</Link>
{children}
</div>
</div>
);
}
export function AuthCard({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"border-border/50 bg-background/80 rounded-3xl border p-6 shadow-xl backdrop-blur-xl sm:p-8",
className,
)}
>
{children}
</div>
);
}
export function AuthCardHeader({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="mb-6 space-y-3">
<Logo size="md" animated={false} />
<div className="space-y-1">
<h1 className="font-heading text-2xl font-semibold tracking-tight">
{title}
</h1>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
/** Grid + animated blob backdrop shared by the app shell and marketing pages. */
export function BrandBackground() {
return (
<div className="brand-background pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]" />
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30" />
</div>
);
}
+12 -4
View File
@@ -66,7 +66,7 @@ function LegalSectionBlock({
return (
<section
id={section.id}
className={cn("scroll-mt-24", !isLast && "border-border border-b")}
className={cn("scroll-mt-28", !isLast && "border-border/50 border-b")}
>
<div className="px-6 pt-8 pb-4 sm:px-8">
<h2 className="text-foreground text-lg font-semibold tracking-tight sm:text-xl">
@@ -82,12 +82,20 @@ function LegalSectionBlock({
export function LegalDocument({ sections }: { sections: LegalSection[] }) {
return (
<div className="grid gap-8 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)] lg:items-start">
<aside className="bg-card border-border rounded-lg border p-4 lg:sticky lg:top-8">
<div className="grid gap-6 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)] lg:items-start lg:gap-8">
<aside
className={cn(
"border-border/60 bg-card/70 rounded-2xl border p-4 backdrop-blur-sm lg:sticky lg:top-24",
)}
>
<LegalTableOfContents sections={sections} />
</aside>
<article className="bg-card border-border overflow-hidden rounded-lg border shadow-sm">
<article
className={cn(
"border-border/60 bg-card/70 overflow-hidden rounded-3xl border shadow-xl backdrop-blur-sm",
)}
>
{sections.map((section, index) => (
<LegalSectionBlock
key={section.id}
+34 -32
View File
@@ -1,9 +1,12 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Logo } from "~/components/branding/logo";
import {
MarketingFooter,
MarketingHeader,
MarketingPageShell,
marketingSurfaceClass,
} from "~/components/marketing/marketing-chrome";
import { LEGAL_LAST_UPDATED } from "~/lib/legal";
import { cn } from "~/lib/utils";
import { env } from "~/env";
type LegalPageShellProps = {
title: string;
@@ -16,35 +19,34 @@ export function LegalPageShell({
description,
children,
}: LegalPageShellProps) {
const allowRegistration = env.DISABLE_SIGNUPS !== true;
return (
<div className="bg-background min-h-screen">
<header className="border-border bg-card/80 border-b backdrop-blur-sm">
<div className="container mx-auto flex max-w-6xl flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<Logo size="sm" />
<Link href="/">
<Button variant="outline" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to app
</Button>
</Link>
</div>
<MarketingPageShell>
<MarketingHeader allowRegistration={allowRegistration} />
<div className="max-w-3xl space-y-2">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">{title}</h1>
{description ? (
<p className="text-muted-foreground text-base leading-relaxed">{description}</p>
) : null}
<p className="text-muted-foreground text-sm">
Last updated {LEGAL_LAST_UPDATED}
</p>
</div>
</div>
</header>
<div
className={cn(
marketingSurfaceClass,
"mb-8 space-y-3 px-6 py-8 sm:px-8 sm:py-10",
)}
>
<h1 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
{title}
</h1>
{description ? (
<p className="text-muted-foreground max-w-3xl text-base leading-7">
{description}
</p>
) : null}
<p className="text-muted-foreground text-sm">
Last updated {LEGAL_LAST_UPDATED}
</p>
</div>
<main className="container mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
{children}
</main>
</div>
<main>{children}</main>
<MarketingFooter />
</MarketingPageShell>
);
}
@@ -0,0 +1,265 @@
import {
BarChart3,
Clock,
FileText,
LayoutDashboard,
Receipt,
Settings,
Timer,
Users,
} from "lucide-react";
import { BrowserFrame } from "~/components/marketing/browser-frame";
import { cn } from "~/lib/utils";
function MockSidebar({ active }: { active: "dashboard" | "invoices" | "time" }) {
const items = [
{ id: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
{ id: "invoices" as const, label: "Invoices", icon: FileText },
{ id: "time" as const, label: "Time clock", icon: Timer },
{ id: "clients" as const, label: "Clients", icon: Users },
{ id: "expenses" as const, label: "Expenses", icon: Receipt },
{ id: "reports" as const, label: "Reports", icon: BarChart3 },
];
return (
<aside className="bg-card/90 hidden w-36 shrink-0 border-r p-3 sm:block">
<div className="text-primary mb-4 font-mono text-xs font-bold">$ beenvoice</div>
<nav className="space-y-0.5">
{items.map((item) => {
const Icon = item.icon;
const isActive = item.id === active;
return (
<div
key={item.id}
className={cn(
"flex items-center gap-2 rounded-lg px-2 py-1.5 text-[10px] font-medium",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground",
)}
>
<Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{item.label}</span>
</div>
);
})}
</nav>
<div className="text-muted-foreground mt-6 flex items-center gap-2 px-2 text-[10px]">
<Settings className="h-3 w-3" />
Settings
</div>
</aside>
);
}
function StatusBadge({
children,
tone,
}: {
children: React.ReactNode;
tone: "draft" | "sent" | "paid" | "overdue";
}) {
const tones = {
draft: "bg-muted text-muted-foreground",
sent: "bg-blue-500/10 text-blue-700 dark:text-blue-300",
paid: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
overdue: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
};
return (
<span
className={cn(
"inline-flex rounded-full px-2 py-0.5 text-[9px] font-medium",
tones[tone],
)}
>
{children}
</span>
);
}
export function InvoicesScreenshot({ className }: { className?: string }) {
const rows = [
{ client: "Northwind Studio", id: "INV-1042", amount: "$1,850.00", status: "sent" as const },
{ client: "Harbor & Co.", id: "INV-1041", amount: "$640.00", status: "paid" as const },
{ client: "Lumen Creative", id: "INV-1040", amount: "$2,100.00", status: "draft" as const },
{ client: "Field Notes Ltd", id: "INV-1039", amount: "$420.00", status: "overdue" as const },
];
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard/invoices">
<div className="flex min-h-[280px] sm:min-h-[320px]">
<MockSidebar active="invoices" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h3 className="font-heading text-sm font-semibold sm:text-base">
Invoices
</h3>
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
Draft, send, and track what you&apos;re owed.
</p>
</div>
<div className="bg-primary text-primary-foreground rounded-lg px-2.5 py-1 text-[10px] font-medium">
New invoice
</div>
</div>
<div className="bg-card overflow-hidden rounded-xl border">
<div className="text-muted-foreground grid grid-cols-[1fr_auto_auto] gap-2 border-b px-3 py-2 text-[9px] font-medium uppercase tracking-wide sm:grid-cols-[1.2fr_0.8fr_auto_auto] sm:px-4">
<span>Client</span>
<span className="hidden sm:block">Invoice</span>
<span>Amount</span>
<span>Status</span>
</div>
{rows.map((row) => (
<div
key={row.id}
className="grid grid-cols-[1fr_auto_auto] items-center gap-2 border-b px-3 py-2.5 text-[10px] last:border-0 sm:grid-cols-[1.2fr_0.8fr_auto_auto] sm:px-4 sm:text-xs"
>
<span className="truncate font-medium">{row.client}</span>
<span className="text-muted-foreground hidden sm:block">{row.id}</span>
<span className="tabular-nums">{row.amount}</span>
<StatusBadge tone={row.status}>
{row.status.charAt(0).toUpperCase() + row.status.slice(1)}
</StatusBadge>
</div>
))}
</div>
</div>
</div>
</BrowserFrame>
);
}
export function TimeClockScreenshot({ className }: { className?: string }) {
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard/time-clock">
<div className="flex min-h-[260px] sm:min-h-[300px]">
<MockSidebar active="time" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
<div className="mb-4">
<h3 className="font-heading text-sm font-semibold sm:text-base">
Time clock
</h3>
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
Track billable hours and roll them into invoices.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="bg-card rounded-xl border p-4">
<div className="text-muted-foreground mb-2 flex items-center gap-1.5 text-[10px] font-medium">
<Timer className="h-3 w-3" />
Active session
</div>
<div className="font-heading text-2xl font-semibold tabular-nums sm:text-3xl">
02:14:38
</div>
<p className="text-muted-foreground mt-1 text-[10px]">
Brand refresh Northwind Studio
</p>
<div className="mt-4 flex gap-2">
<div className="bg-primary text-primary-foreground rounded-lg px-3 py-1.5 text-[10px] font-medium">
Stop
</div>
<div className="border-border rounded-lg border px-3 py-1.5 text-[10px] font-medium">
Pause
</div>
</div>
</div>
<div className="bg-card rounded-xl border p-4">
<div className="text-muted-foreground mb-3 flex items-center gap-1.5 text-[10px] font-medium">
<Clock className="h-3 w-3" />
Recent entries
</div>
<div className="space-y-2.5">
{[
{ label: "Wireframes", time: "1h 20m", client: "Harbor & Co." },
{ label: "Copy edits", time: "45m", client: "Lumen Creative" },
{ label: "Kickoff call", time: "30m", client: "Field Notes Ltd" },
].map((entry) => (
<div
key={entry.label}
className="flex items-center justify-between gap-2 text-[10px] sm:text-xs"
>
<div className="min-w-0">
<p className="truncate font-medium">{entry.label}</p>
<p className="text-muted-foreground truncate">{entry.client}</p>
</div>
<span className="text-muted-foreground shrink-0 tabular-nums">
{entry.time}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</BrowserFrame>
);
}
export function DashboardScreenshot({ className }: { className?: string }) {
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard">
<div className="flex min-h-[260px] sm:min-h-[300px]">
<MockSidebar active="dashboard" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
<div className="mb-4">
<h3 className="font-heading text-sm font-semibold sm:text-base">
Good afternoon
</h3>
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
Here&apos;s what needs your attention.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="bg-card rounded-xl border p-4">
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
Awaiting payment
</p>
<p className="font-heading mt-2 text-lg font-semibold">3 invoices</p>
<p className="text-muted-foreground mt-1 text-[10px]">
Follow up on sent invoices when you&apos;re ready.
</p>
</div>
<div className="bg-card rounded-xl border p-4">
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
Timer running
</p>
<p className="font-heading mt-2 text-lg font-semibold tabular-nums">
02:14:38
</p>
<p className="text-muted-foreground mt-1 text-[10px]">
Northwind Studio · Brand refresh
</p>
</div>
</div>
<div className="bg-card mt-3 rounded-xl border p-4">
<p className="mb-3 text-[10px] font-medium">Recent activity</p>
<div className="space-y-2">
{[
"Invoice INV-1042 sent to Northwind Studio",
"Timer started for Brand refresh",
"Client Harbor & Co. updated",
].map((line) => (
<div
key={line}
className="text-muted-foreground flex items-center gap-2 text-[10px] sm:text-xs"
>
<span className="bg-primary/60 h-1.5 w-1.5 shrink-0 rounded-full" />
<span className="truncate">{line}</span>
</div>
))}
</div>
</div>
</div>
</div>
</BrowserFrame>
);
}
@@ -0,0 +1,32 @@
import { cn } from "~/lib/utils";
export function BrowserFrame({
children,
className,
url = "beenvoice.app/dashboard",
}: {
children: React.ReactNode;
className?: string;
url?: string;
}) {
return (
<div
className={cn(
"border-border/80 bg-card overflow-hidden rounded-2xl border shadow-2xl shadow-black/8 ring-1 ring-black/5",
className,
)}
>
<div className="bg-muted/50 flex items-center gap-3 border-b px-4 py-2.5">
<div className="flex gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f57]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#febc2e]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#28c840]" />
</div>
<div className="bg-background/70 text-muted-foreground mx-auto flex h-7 w-full max-w-sm items-center justify-center rounded-lg px-3 text-[11px] tracking-wide">
{url}
</div>
</div>
<div className="bg-dashboard overflow-hidden">{children}</div>
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
import Link from "next/link";
import {
ArrowRight,
Clock,
FileText,
Mail,
Receipt,
Repeat,
Timer,
Users,
} from "lucide-react";
import {
DashboardScreenshot,
InvoicesScreenshot,
TimeClockScreenshot,
} from "~/components/marketing/app-screenshots";
import {
MarketingFooter,
MarketingHeader,
MarketingPageShell,
marketingSurfaceClass,
} from "~/components/marketing/marketing-chrome";
import { Button } from "~/components/ui/button";
import { brand } from "~/lib/branding";
import { cn } from "~/lib/utils";
const features = [
{
icon: FileText,
title: "Invoices that stay organized",
description:
"Draft line items, apply taxes, send a link, and export a polished PDF when you need a file.",
},
{
icon: Users,
title: "Clients and businesses",
description:
"Keep the people and companies you bill in one place, with the details you reuse on every invoice.",
},
{
icon: Timer,
title: "Built-in time clock",
description:
"Track billable hours as you work, then pull them straight into an invoice without retyping.",
},
{
icon: Repeat,
title: "Recurring invoices",
description:
"Set up retainers and subscriptions once, then let beenvoice generate the next invoice on schedule.",
},
{
icon: Receipt,
title: "Expenses",
description:
"Log costs alongside your work so nothing gets lost before you bill it out.",
},
{
icon: Mail,
title: "Send and follow up",
description:
"Email invoices from the app and keep status visible from draft through paid.",
},
];
function FeatureRow({
title,
description,
children,
reverse = false,
}: {
title: string;
description: string;
children: React.ReactNode;
reverse?: boolean;
}) {
return (
<div
className={cn(
"grid items-center gap-10 lg:grid-cols-2 lg:gap-16",
reverse && "lg:[&>*:first-child]:order-2",
)}
>
<div className="space-y-4">
<h3 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
{title}
</h3>
<p className="text-muted-foreground max-w-lg text-base leading-7">
{description}
</p>
</div>
<div>{children}</div>
</div>
);
}
export function LandingPage({ allowRegistration }: { allowRegistration: boolean }) {
return (
<MarketingPageShell>
<MarketingHeader allowRegistration={allowRegistration} />
<section className="pb-16 sm:pb-20 lg:pb-24">
<div className="mx-auto max-w-3xl text-center">
<p className="text-primary mb-4 text-sm font-medium tracking-wide uppercase">
Personal invoicing workspace
</p>
<h1 className="font-heading text-4xl leading-[1.1] font-bold tracking-tight sm:text-5xl lg:text-6xl">
Run your freelance admin from one place.
</h1>
<p className="text-muted-foreground mx-auto mt-5 max-w-2xl text-base leading-7 sm:text-lg">
{brand.name} helps you manage clients, track time, send invoices,
and stay on top of getting paid without the weight of a full
accounting suite.
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link href="/auth/signin">
<Button size="lg" className="h-11 px-6">
Open workspace
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
{allowRegistration && (
<Link href="/auth/register">
<Button variant="outline" size="lg" className="h-11 px-6">
Create account
</Button>
</Link>
)}
</div>
</div>
<div className="relative mx-auto mt-14 max-w-5xl lg:mt-16">
<InvoicesScreenshot className="w-full" />
</div>
</section>
<section className="border-border/50 border-t py-16 sm:py-20">
<div className="mx-auto max-w-2xl text-center">
<h2 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
Everything around getting paid, in one flow
</h2>
<p className="text-muted-foreground mt-4 text-base leading-7">
{brand.tagline}. Built for one person doing real client work not
enterprise dashboards you&apos;ll never open.
</p>
</div>
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{features.map((feature) => {
const Icon = feature.icon;
return (
<div
key={feature.title}
className="bg-card/70 hover:bg-card/90 border-border/60 rounded-2xl border p-5 backdrop-blur-sm transition-colors"
>
<div className="bg-primary/10 text-primary mb-4 inline-flex rounded-xl p-2.5">
<Icon className="h-4 w-4" />
</div>
<h3 className="text-sm font-semibold sm:text-base">
{feature.title}
</h3>
<p className="text-muted-foreground mt-2 text-sm leading-6">
{feature.description}
</p>
</div>
);
})}
</div>
</section>
<section className="border-border/50 space-y-20 border-t py-16 sm:space-y-24 sm:py-20">
<FeatureRow
title="See your week at a glance"
description="Open the dashboard to check what's waiting on payment, whether a timer is still running, and what changed recently — without wading through reports."
>
<DashboardScreenshot />
</FeatureRow>
<FeatureRow
title="Track time where you already work"
description="Start a timer for the client and project you're on. When the work is done, turn those hours into invoice line items in a few clicks."
reverse
>
<TimeClockScreenshot />
</FeatureRow>
<FeatureRow
title="Invoices that look professional"
description="Clean layouts, PDF export, and a shareable link for clients. Mark invoices sent or paid as your pipeline moves."
>
<InvoicesScreenshot />
</FeatureRow>
</section>
<section>
<div
className={cn(
marketingSurfaceClass,
"bg-card/80 relative overflow-hidden px-6 py-10 text-center sm:px-10 sm:py-12",
)}
>
<div className="relative">
<div className="bg-primary/10 text-primary mx-auto mb-4 inline-flex rounded-full p-3">
<Clock className="h-5 w-5" />
</div>
<h2 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Ready to simplify your invoicing?
</h2>
<p className="text-muted-foreground mx-auto mt-3 max-w-xl text-sm leading-6 sm:text-base">
Sign in to your workspace or create an account to start with
clients, invoices, and time tracking in minutes.
</p>
<div className="mt-6 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link href="/auth/signin">
<Button size="lg" className="h-11 px-6">
Open workspace
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
{allowRegistration && (
<Link href="/auth/register">
<Button variant="outline" size="lg" className="h-11 px-6">
Create account
</Button>
</Link>
)}
</div>
</div>
</div>
</section>
<MarketingFooter />
</MarketingPageShell>
);
}
@@ -0,0 +1,84 @@
import Link from "next/link";
import { Logo } from "~/components/branding/logo";
import { Button } from "~/components/ui/button";
import { brand } from "~/lib/branding";
import { cn } from "~/lib/utils";
export const marketingSurfaceClass =
"border-border/50 bg-background/80 rounded-3xl border shadow-xl backdrop-blur-xl";
export function MarketingPageShell({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="bg-dashboard text-foreground relative min-h-screen">
<div className="mx-auto w-full max-w-6xl px-5 pt-4 pb-6 sm:px-6 sm:pt-5 sm:pb-8 lg:px-8">
{children}
</div>
</div>
);
}
export function MarketingHeader({
allowRegistration = true,
sticky = true,
}: {
allowRegistration?: boolean;
sticky?: boolean;
}) {
return (
<header
className={cn(
marketingSurfaceClass,
"mb-8 flex items-center justify-between gap-4 px-4 py-3 sm:px-5",
sticky && "sticky top-4 z-20 sm:top-5",
)}
>
<Link href="/">
<Logo animated={false} />
</Link>
<nav className="flex items-center gap-2">
<Link href="/auth/signin">
<Button variant="ghost" size="sm">
Sign in
</Button>
</Link>
{allowRegistration && (
<Link href="/auth/register">
<Button size="sm">Create account</Button>
</Link>
)}
</nav>
</header>
);
}
export function MarketingFooter({ className }: { className?: string }) {
return (
<footer
className={cn(
marketingSurfaceClass,
"text-muted-foreground mt-8 flex flex-col gap-3 px-4 py-4 text-sm sm:flex-row sm:items-center sm:justify-between sm:px-5",
className,
)}
>
<span>© 2026 {brand.name}</span>
<div className="flex gap-5">
<Link
href="/privacy"
className="hover:text-foreground transition-colors"
>
Privacy Policy
</Link>
<Link
href="/terms"
className="hover:text-foreground transition-colors"
>
Terms of Service
</Link>
</div>
</footer>
);
}
@@ -0,0 +1,235 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { api } from "~/trpc/react";
import {
AnimationPreferencesContext,
DEFAULT_PREFERS_REDUCED,
DEFAULT_SPEED,
applyPreferencesToDOM,
clampSpeed,
readLocalStorage,
writeLocalStorage,
type AnimationPreferencesContextValue,
type AnimationPreferencesProviderProps,
} from "~/components/providers/animation-preferences-provider";
type PartialPrefs = {
prefersReducedMotion?: boolean;
animationSpeedMultiplier?: number;
};
/** Dashboard animation preferences with tRPC sync. Must render inside TRPCReactProvider. */
export function AnimationPreferencesProviderSynced({
children,
initial,
autoSync = true,
}: AnimationPreferencesProviderProps & { autoSync?: boolean }) {
const updateMutation = api.settings.updateAnimationPreferences.useMutation();
const { data: serverPrefs } = api.settings.getAnimationPreferences.useQuery(
undefined,
{
refetchOnWindowFocus: false,
staleTime: 60_000,
retry: false,
},
);
const [prefersReducedMotion, setPrefersReducedMotion] = useState<boolean>(
initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED,
);
const [animationSpeedMultiplier, setAnimationSpeedMultiplier] =
useState<number>(
clampSpeed(initial?.animationSpeedMultiplier ?? DEFAULT_SPEED),
);
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
const pendingSyncRef = useRef<PartialPrefs | null>(null);
const isHydratedRef = useRef(false);
const serverHydratedRef = useRef(false);
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const stored = readLocalStorage();
const systemReduced = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches;
const finalPrefers =
stored?.prefersReducedMotion ??
initial?.prefersReducedMotion ??
systemReduced ??
DEFAULT_PREFERS_REDUCED;
const finalSpeed = clampSpeed(
stored?.animationSpeedMultiplier ??
initial?.animationSpeedMultiplier ??
DEFAULT_SPEED,
);
// eslint-disable-next-line react-hooks/set-state-in-effect
setPrefersReducedMotion(finalPrefers);
setAnimationSpeedMultiplier(finalSpeed);
applyPreferencesToDOM({
prefersReducedMotion: finalPrefers,
animationSpeedMultiplier: finalSpeed,
});
isHydratedRef.current = true;
}, [initial?.prefersReducedMotion, initial?.animationSpeedMultiplier]);
const performUpdate = useCallback(
(patch: PartialPrefs, opts?: { sync?: boolean }) => {
setIsUpdating(true);
setPrefersReducedMotion((prev) => patch.prefersReducedMotion ?? prev);
setAnimationSpeedMultiplier((prev) =>
clampSpeed(patch.animationSpeedMultiplier ?? prev),
);
const normalizedPatch: PartialPrefs = { ...patch };
if (
normalizedPatch.prefersReducedMotion === true &&
normalizedPatch.animationSpeedMultiplier === undefined &&
animationSpeedMultiplier !== 1
) {
normalizedPatch.animationSpeedMultiplier = 1;
}
const nextReduced =
normalizedPatch.prefersReducedMotion ?? prefersReducedMotion;
let nextSpeed = clampSpeed(
normalizedPatch.animationSpeedMultiplier ?? animationSpeedMultiplier,
);
if (nextReduced && nextSpeed !== 1) {
nextSpeed = 1;
normalizedPatch.animationSpeedMultiplier ??= 1;
}
const newPrefs = {
prefersReducedMotion: nextReduced,
animationSpeedMultiplier: nextSpeed,
};
applyPreferencesToDOM(newPrefs);
writeLocalStorage(newPrefs);
const shouldSync = opts?.sync ?? autoSync;
if (shouldSync && serverPrefs) {
pendingSyncRef.current = {
prefersReducedMotion: patch.prefersReducedMotion,
animationSpeedMultiplier: patch.animationSpeedMultiplier,
};
updateMutation.mutate(
{
...(normalizedPatch.prefersReducedMotion !== undefined && {
prefersReducedMotion: normalizedPatch.prefersReducedMotion,
}),
...(normalizedPatch.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: clampSpeed(
normalizedPatch.animationSpeedMultiplier,
),
}),
},
{
onSuccess: () => {
setLastSyncedAt(Date.now());
pendingSyncRef.current = null;
setIsUpdating(false);
},
onError: () => {
setIsUpdating(false);
},
},
);
} else {
setIsUpdating(false);
}
},
[
prefersReducedMotion,
animationSpeedMultiplier,
autoSync,
updateMutation,
serverPrefs,
],
);
useEffect(() => {
if (!isHydratedRef.current) return;
if (serverHydratedRef.current) return;
if (!serverPrefs) return;
const localIsDefault =
prefersReducedMotion === DEFAULT_PREFERS_REDUCED &&
animationSpeedMultiplier === DEFAULT_SPEED;
const differs =
serverPrefs.prefersReducedMotion !== prefersReducedMotion ||
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect
performUpdate(
{
prefersReducedMotion: serverPrefs.prefersReducedMotion,
animationSpeedMultiplier: serverPrefs.animationSpeedMultiplier,
},
{ sync: false },
);
}
serverHydratedRef.current = true;
}, [
serverPrefs,
performUpdate,
prefersReducedMotion,
animationSpeedMultiplier,
]);
const updatePreferences = useCallback<
AnimationPreferencesContextValue["updatePreferences"]
>(
(patch, opts) => {
performUpdate(patch, opts);
},
[performUpdate],
);
const handleSetReduced = useCallback(
(val: boolean) => {
updatePreferences({ prefersReducedMotion: val });
},
[updatePreferences],
);
const handleSetSpeed = useCallback(
(val: number) => {
updatePreferences({ animationSpeedMultiplier: clampSpeed(val) });
},
[updatePreferences],
);
const value: AnimationPreferencesContextValue = {
prefersReducedMotion,
animationSpeedMultiplier,
updatePreferences,
setPrefersReducedMotion: handleSetReduced,
setAnimationSpeedMultiplier: handleSetSpeed,
isUpdating: isUpdating || updateMutation.isPending,
lastSyncedAt,
};
return (
<AnimationPreferencesContext.Provider value={value}>
{children}
</AnimationPreferencesContext.Provider>
);
}
@@ -1,58 +1,12 @@
"use client";
/**
* AnimationPreferencesProvider
*
* Centralized manager for user animation / motion preferences:
* - prefersReducedMotion (boolean)
* - animationSpeedMultiplier (0.25x 4x)
*
* Responsibilities:
* 1. Hydrate from (priority):
* - Inline early localStorage value (if already written by an inline script in layout)
* - Existing localStorage value
* - Server value (tRPC - user profile)
* - Initial props (e.g. server-fetched)
* - System media query (prefers-reduced-motion)
* 2. Apply preferences to:
* - documentElement class list (adds / removes .user-reduce-motion)
* - CSS custom properties: --animation-speed-fast/normal/slow
* 3. Persist to localStorage
* 4. Sync to server via tRPC mutation (debounced & resilient)
*
* Usage:
* <AnimationPreferencesProvider
* initial={{
* prefersReducedMotion: serverValue.prefersReducedMotion,
* animationSpeedMultiplier: serverValue.animationSpeedMultiplier
* }}
* >
* <App />
* </AnimationPreferencesProvider>
*
* const {
* prefersReducedMotion,
* animationSpeedMultiplier,
* updatePreferences,
* isUpdating
* } = useAnimationPreferences();
*
* updatePreferences({ animationSpeedMultiplier: 1.5 });
*
* NOTE: After integrating this provider, remove the duplicated logic
* from the settings page (SettingsContent) and call updatePreferences()
* instead of directly manipulating DOM / CSS variables there.
*/
import React, {
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { api } from "~/trpc/react";
type AnimationPreferences = {
prefersReducedMotion: boolean;
@@ -61,7 +15,7 @@ type AnimationPreferences = {
type PartialPrefs = Partial<AnimationPreferences>;
interface AnimationPreferencesContextValue extends AnimationPreferences {
export interface AnimationPreferencesContextValue extends AnimationPreferences {
updatePreferences: (patch: PartialPrefs, opts?: { sync?: boolean }) => void;
setPrefersReducedMotion: (val: boolean) => void;
setAnimationSpeedMultiplier: (val: number) => void;
@@ -69,33 +23,26 @@ interface AnimationPreferencesContextValue extends AnimationPreferences {
lastSyncedAt: number | null;
}
interface AnimationPreferencesProviderProps {
export interface AnimationPreferencesProviderProps {
children: React.ReactNode;
/**
* Optional initial values (e.g. from server / tRPC prefetch).
*/
initial?: PartialPrefs;
/**
* Disable auto-sync to server (mostly for test environments).
*/
autoSync?: boolean;
}
const STORAGE_KEY = "bv.animation.prefs";
const MIN_SPEED = 0.25;
const MAX_SPEED = 4;
const DEFAULT_SPEED = 1;
const DEFAULT_PREFERS_REDUCED = false;
export const STORAGE_KEY = "bv.animation.prefs";
export const MIN_SPEED = 0.25;
export const MAX_SPEED = 4;
export const DEFAULT_SPEED = 1;
export const DEFAULT_PREFERS_REDUCED = false;
const AnimationPreferencesContext =
export const AnimationPreferencesContext =
createContext<AnimationPreferencesContextValue | null>(null);
function clampSpeed(value: number): number {
export function clampSpeed(value: number): number {
if (Number.isNaN(value)) return DEFAULT_SPEED;
return Math.min(MAX_SPEED, Math.max(MIN_SPEED, value));
}
function readLocalStorage(): PartialPrefs | null {
export function readLocalStorage(): PartialPrefs | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
@@ -122,7 +69,7 @@ function readLocalStorage(): PartialPrefs | null {
}
}
function writeLocalStorage(prefs: AnimationPreferences) {
export function writeLocalStorage(prefs: AnimationPreferences) {
try {
localStorage.setItem(
STORAGE_KEY,
@@ -136,18 +83,16 @@ function writeLocalStorage(prefs: AnimationPreferences) {
}
}
function applyPreferencesToDOM(prefs: AnimationPreferences) {
export function applyPreferencesToDOM(prefs: AnimationPreferences) {
if (typeof document === "undefined") return;
const root = document.documentElement;
// Class toggle
if (prefs.prefersReducedMotion) {
root.classList.add("user-reduce-motion");
} else {
root.classList.remove("user-reduce-motion");
}
// Derive effective speeds
const multiplier = prefs.animationSpeedMultiplier || 1;
const fast = prefs.prefersReducedMotion
@@ -165,28 +110,11 @@ function applyPreferencesToDOM(prefs: AnimationPreferences) {
root.style.setProperty("--animation-speed-slow", `${slow}s`);
}
/**
* Provider component
*/
/** Local-only animation preferences for marketing and auth pages (no tRPC). */
export function AnimationPreferencesProvider({
children,
initial,
autoSync = true,
}: AnimationPreferencesProviderProps) {
const updateMutation = api.settings.updateAnimationPreferences.useMutation();
// Server query - tRPC will handle authentication internally
// The query will only succeed if the user is authenticated
const { data: serverPrefs } = api.settings.getAnimationPreferences.useQuery(
undefined,
{
enabled: true, // Let tRPC handle auth
refetchOnWindowFocus: false,
staleTime: 60_000,
retry: false, // Don't retry if not authenticated
},
);
const [prefersReducedMotion, setPrefersReducedMotion] = useState<boolean>(
initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED,
);
@@ -194,17 +122,10 @@ export function AnimationPreferencesProvider({
useState<number>(
clampSpeed(initial?.animationSpeedMultiplier ?? DEFAULT_SPEED),
);
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
const pendingSyncRef = useRef<PartialPrefs | null>(null);
const isHydratedRef = useRef(false);
const serverHydratedRef = useRef(false);
const [isUpdating, setIsUpdating] = useState(false);
// Hydration: run once on mount (local + system + initial)
useEffect(() => {
if (typeof window === "undefined") return;
const stored = readLocalStorage();
const systemReduced = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches;
@@ -220,171 +141,47 @@ export function AnimationPreferencesProvider({
DEFAULT_SPEED,
);
// eslint-disable-next-line react-hooks/set-state-in-effect -- Hydrate preferences from localStorage/system settings on mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPrefersReducedMotion(finalPrefers);
setAnimationSpeedMultiplier(finalSpeed);
applyPreferencesToDOM({
prefersReducedMotion: finalPrefers,
animationSpeedMultiplier: finalSpeed,
});
isHydratedRef.current = true;
}, [initial?.prefersReducedMotion, initial?.animationSpeedMultiplier]);
/**
* Core updater
*/
const performUpdate = useCallback(
(patch: PartialPrefs, opts?: { sync?: boolean }) => {
setIsUpdating(true);
setPrefersReducedMotion((prev) => patch.prefersReducedMotion ?? prev);
setAnimationSpeedMultiplier((prev) =>
clampSpeed(patch.animationSpeedMultiplier ?? prev),
);
// Normalize patch (avoid mutating the original function argument directly)
const normalizedPatch: PartialPrefs = { ...patch };
// If user enables reduced motion, force the animation speed multiplier to 1x (unless already specified)
if (
normalizedPatch.prefersReducedMotion === true &&
normalizedPatch.animationSpeedMultiplier === undefined &&
animationSpeedMultiplier !== 1
) {
normalizedPatch.animationSpeedMultiplier = 1;
}
const nextReduced =
normalizedPatch.prefersReducedMotion ?? prefersReducedMotion;
let nextSpeed = clampSpeed(
normalizedPatch.animationSpeedMultiplier ?? animationSpeedMultiplier,
);
// Enforce 1x when reduced motion is active
if (nextReduced && nextSpeed !== 1) {
nextSpeed = 1;
normalizedPatch.animationSpeedMultiplier ??= 1;
}
const newPrefs: AnimationPreferences = {
prefersReducedMotion: nextReduced,
animationSpeedMultiplier: nextSpeed,
};
// Apply to DOM immediately
applyPreferencesToDOM(newPrefs);
// Persist locally
writeLocalStorage(newPrefs);
// Optionally sync to server
const shouldSync = opts?.sync ?? autoSync;
if (shouldSync && serverPrefs) {
// If serverPrefs exists, user is authenticated
pendingSyncRef.current = {
prefersReducedMotion: patch.prefersReducedMotion,
animationSpeedMultiplier: patch.animationSpeedMultiplier,
};
updateMutation.mutate(
{
...(normalizedPatch.prefersReducedMotion !== undefined && {
prefersReducedMotion: normalizedPatch.prefersReducedMotion,
}),
...(normalizedPatch.animationSpeedMultiplier !== undefined && {
animationSpeedMultiplier: clampSpeed(
normalizedPatch.animationSpeedMultiplier,
),
}),
},
{
onSuccess: () => {
setLastSyncedAt(Date.now());
pendingSyncRef.current = null;
setIsUpdating(false);
},
onError: () => {
setIsUpdating(false);
},
},
);
} else {
setIsUpdating(false);
}
},
[
prefersReducedMotion,
animationSpeedMultiplier,
autoSync,
updateMutation,
serverPrefs,
],
);
// Secondary hydration: apply server values if they differ AND user hasn't customized locally yet.
useEffect(() => {
if (!isHydratedRef.current) return;
if (serverHydratedRef.current) return;
if (!serverPrefs) return; // No server prefs means not authenticated or not loaded yet
const localIsDefault =
prefersReducedMotion === DEFAULT_PREFERS_REDUCED &&
animationSpeedMultiplier === DEFAULT_SPEED;
const differs =
serverPrefs.prefersReducedMotion !== prefersReducedMotion ||
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reconcile loaded server preferences once after query hydration.
performUpdate(
{
prefersReducedMotion: serverPrefs.prefersReducedMotion,
animationSpeedMultiplier: serverPrefs.animationSpeedMultiplier,
},
{ sync: false }, // Do not echo immediately back to server
);
}
serverHydratedRef.current = true;
}, [
serverPrefs,
performUpdate,
prefersReducedMotion,
animationSpeedMultiplier,
]);
const updatePreferences = useCallback<
AnimationPreferencesContextValue["updatePreferences"]
>(
(patch, opts) => {
performUpdate(patch, opts);
},
[performUpdate],
);
>((patch) => {
const nextReduced = patch.prefersReducedMotion ?? prefersReducedMotion;
let nextSpeed = clampSpeed(
patch.animationSpeedMultiplier ?? animationSpeedMultiplier,
);
if (nextReduced && nextSpeed !== 1) {
nextSpeed = 1;
}
// Dedicated setters (they sync by default)
const handleSetReduced = useCallback(
(val: boolean) => {
updatePreferences({ prefersReducedMotion: val });
},
[updatePreferences],
);
const handleSetSpeed = useCallback(
(val: number) => {
updatePreferences({ animationSpeedMultiplier: clampSpeed(val) });
},
[updatePreferences],
);
setPrefersReducedMotion(nextReduced);
setAnimationSpeedMultiplier(nextSpeed);
applyPreferencesToDOM({
prefersReducedMotion: nextReduced,
animationSpeedMultiplier: nextSpeed,
});
writeLocalStorage({
prefersReducedMotion: nextReduced,
animationSpeedMultiplier: nextSpeed,
});
}, [prefersReducedMotion, animationSpeedMultiplier]);
const value: AnimationPreferencesContextValue = {
prefersReducedMotion,
animationSpeedMultiplier,
updatePreferences,
setPrefersReducedMotion: handleSetReduced,
setAnimationSpeedMultiplier: handleSetSpeed,
isUpdating: isUpdating || updateMutation.isPending,
lastSyncedAt,
setPrefersReducedMotion: (val) => updatePreferences({ prefersReducedMotion: val }),
setAnimationSpeedMultiplier: (val) =>
updatePreferences({ animationSpeedMultiplier: clampSpeed(val) }),
isUpdating: false,
lastSyncedAt: null,
};
return (
@@ -394,13 +191,9 @@ export function AnimationPreferencesProvider({
);
}
/**
* Hook consumer
*/
export function useAnimationPreferences(): AnimationPreferencesContextValue {
const ctx = useContext(AnimationPreferencesContext);
if (!ctx) {
// Fallback instead of throwing to prevent runtime crashes if provider is missing
console.warn("useAnimationPreferences used without provider");
return {
prefersReducedMotion: false,
@@ -421,16 +214,6 @@ export function useAnimationPreferences(): AnimationPreferencesContextValue {
return ctx;
}
/**
* Helper: Inline script snippet (for layout) to minimize FOUC.
* (Not executed here—copy the string contents into a <script dangerouslySetInnerHTML={{__html: ...}} /> early in <head>.)
*
* Example usage in layout.tsx (before loading CSS-heavy content):
*
* <script
* dangerouslySetInnerHTML={{ __html: getInlineAnimationPrefsScript() }}
* />
*/
export function getInlineAnimationPrefsScript(): string {
return `
(function(){
@@ -445,7 +228,6 @@ export function getInlineAnimationPrefsScript(): string {
if (typeof parsed.prefersReducedMotion === 'boolean') {
prefersReduced = parsed.prefersReducedMotion;
} else {
// fallback to system preference if available
prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
if (typeof parsed.animationSpeedMultiplier === 'number') {
@@ -0,0 +1,18 @@
"use client";
import { AnimationPreferencesProviderSynced } from "~/components/providers/animation-preferences-provider-synced";
import { AppearanceProviderSynced } from "~/components/providers/appearance-provider-synced";
import { TRPCReactProvider } from "~/trpc/react";
/** Full app providers for authenticated workspace routes. */
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<TRPCReactProvider>
<AppearanceProviderSynced>
<AnimationPreferencesProviderSynced>
{children}
</AnimationPreferencesProviderSynced>
</AppearanceProviderSynced>
</TRPCReactProvider>
);
}
@@ -0,0 +1,237 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isHslChannels } from "~/lib/appearance";
import type { ColorMode, ColorTheme, FontPreference, InterfaceTheme, RadiusPreference, SidebarStyle } from "~/lib/branding";
import { api } from "~/trpc/react";
import {
AppearanceContext,
applyAppearance,
defaultAppearance,
readStoredAppearance,
writeStoredAppearance,
type AppearanceContextValue,
type AppearancePatch,
type AppearancePreferences,
} from "~/components/providers/appearance-provider";
type ServerAppearance = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
theme: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: AppearancePreferences["pdfTemplate"];
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
function getServerAppearancePatch(
serverAppearance: ServerAppearance,
): AppearancePatch {
return {
interfaceTheme: serverAppearance.interfaceTheme,
bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference,
sidebarStyle: serverAppearance.sidebarStyle,
colorMode: serverAppearance.theme,
colorTheme: serverAppearance.colorTheme,
customColor: serverAppearance.customColor,
brandName: serverAppearance.brandName,
brandTagline: serverAppearance.brandTagline,
brandLogoText: serverAppearance.brandLogoText,
brandIcon: serverAppearance.brandIcon,
pdfTemplate: serverAppearance.pdfTemplate,
pdfAccentColor: serverAppearance.pdfAccentColor,
pdfFooterText: serverAppearance.pdfFooterText,
pdfShowLogo: serverAppearance.pdfShowLogo,
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
};
}
/** Dashboard appearance provider with tRPC theme sync. Must render inside TRPCReactProvider. */
export function AppearanceProviderSynced({
children,
}: {
children: React.ReactNode;
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => {
await utils.settings.getTheme.invalidate();
},
onError: () => {
const cachedAppearance = utils.settings.getTheme.getData();
const fallback = cachedAppearance
? {
...defaultAppearance,
...getServerAppearancePatch(cachedAppearance),
}
: defaultAppearance;
setAppearance(fallback);
applyAppearance(fallback);
writeStoredAppearance(fallback);
},
});
const persistAppearance = useCallback(
(patch: AppearancePatch) => {
if (
patch.customColor !== undefined &&
!isHslChannels(patch.customColor)
) {
return;
}
updateMutation.mutate({
interfaceTheme: patch.interfaceTheme,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
},
[updateMutation],
);
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
useEffect(() => {
const storedAppearance = readStoredAppearance();
if (!storedAppearance) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
}, []);
useEffect(() => {
if (!serverAppearance) return;
const next = getServerAppearancePatch(serverAppearance);
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...next }));
}, [serverAppearance]);
useEffect(() => {
applyAppearance(appearance);
writeStoredAppearance(appearance);
}, [appearance]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
}
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
persistAppearance(patch);
},
[persistAppearance],
);
const updateAppearanceDebounced = useCallback(
(patch: AppearancePatch) => {
pendingDebouncedPatchRef.current = {
...pendingDebouncedPatchRef.current,
...patch,
};
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
debounceTimerRef.current = null;
}, 500);
},
[persistAppearance],
);
useEffect(
() => () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
pendingDebouncedPatchRef.current = {};
},
[],
);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
updateAppearance,
updateAppearanceDebounced,
isUpdating: updateMutation.isPending,
}),
[
appearance,
updateAppearance,
updateAppearanceDebounced,
updateMutation.isPending,
],
);
return (
<AppearanceContext.Provider value={value}>
{children}
</AppearanceContext.Provider>
);
}
+29 -187
View File
@@ -6,7 +6,6 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
@@ -35,9 +34,8 @@ import {
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
import { api } from "~/trpc/react";
type AppearancePreferences = {
export type AppearancePreferences = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
@@ -57,37 +55,17 @@ type AppearancePreferences = {
pdfShowPageNumbers: boolean;
};
type AppearancePatch = Partial<AppearancePreferences>;
export type AppearancePatch = Partial<AppearancePreferences>;
type ServerAppearance = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
theme: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
type AppearanceContextValue = AppearancePreferences & {
export type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void;
updateAppearanceDebounced: (patch: AppearancePatch) => void;
isUpdating: boolean;
};
const STORAGE_KEY = "bv.appearance";
export const STORAGE_KEY = "bv.appearance";
const defaultAppearance: AppearancePreferences = {
export const defaultAppearance: AppearancePreferences = {
interfaceTheme: defaultInterfaceTheme,
bodyFontPreference: defaultBodyFontPreference,
headingFontPreference: defaultHeadingFontPreference,
@@ -106,33 +84,10 @@ const defaultAppearance: AppearancePreferences = {
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
};
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
export const AppearanceContext =
createContext<AppearanceContextValue | null>(null);
function getServerAppearancePatch(
serverAppearance: ServerAppearance,
): AppearancePatch {
return {
interfaceTheme: serverAppearance.interfaceTheme,
bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference,
sidebarStyle: serverAppearance.sidebarStyle,
colorMode: serverAppearance.theme,
colorTheme: serverAppearance.colorTheme,
customColor: serverAppearance.customColor,
brandName: serverAppearance.brandName,
brandTagline: serverAppearance.brandTagline,
brandLogoText: serverAppearance.brandLogoText,
brandIcon: serverAppearance.brandIcon,
pdfTemplate: serverAppearance.pdfTemplate,
pdfAccentColor: serverAppearance.pdfAccentColor,
pdfFooterText: serverAppearance.pdfFooterText,
pdfShowLogo: serverAppearance.pdfShowLogo,
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
};
}
function readStoredAppearance(): Partial<AppearancePreferences> | null {
export function readStoredAppearance(): Partial<AppearancePreferences> | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
@@ -201,7 +156,7 @@ function readStoredAppearance(): Partial<AppearancePreferences> | null {
}
}
function writeStoredAppearance(prefs: AppearancePreferences) {
export function writeStoredAppearance(prefs: AppearancePreferences) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
@@ -209,7 +164,7 @@ function writeStoredAppearance(prefs: AppearancePreferences) {
}
}
function applyAppearance(prefs: AppearancePreferences) {
export function applyAppearance(prefs: AppearancePreferences) {
if (typeof document === "undefined") return;
const root = document.documentElement;
@@ -230,6 +185,7 @@ function applyAppearance(prefs: AppearancePreferences) {
}
}
/** Local-only appearance provider for marketing and auth pages (no tRPC). */
export function AppearanceProvider({
children,
}: {
@@ -237,65 +193,6 @@ export function AppearanceProvider({
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => {
await utils.settings.getTheme.invalidate();
},
onError: () => {
const cachedAppearance = utils.settings.getTheme.getData();
const fallback = cachedAppearance
? {
...defaultAppearance,
...getServerAppearancePatch(cachedAppearance),
}
: defaultAppearance;
setAppearance(fallback);
applyAppearance(fallback);
writeStoredAppearance(fallback);
},
});
const persistAppearance = useCallback(
(patch: AppearancePatch) => {
if (
patch.customColor !== undefined &&
!isHslChannels(patch.customColor)
) {
return;
}
updateMutation.mutate({
interfaceTheme: patch.interfaceTheme,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
},
[updateMutation],
);
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
useEffect(() => {
const storedAppearance = readStoredAppearance();
@@ -305,92 +202,37 @@ export function AppearanceProvider({
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
}, []);
useEffect(() => {
if (!serverAppearance) return;
const next = getServerAppearancePatch(serverAppearance);
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...next }));
}, [serverAppearance]);
useEffect(() => {
applyAppearance(appearance);
writeStoredAppearance(appearance);
}, [appearance]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
}
const updateAppearance = useCallback((patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
}, []);
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
persistAppearance(patch);
},
[persistAppearance],
);
const updateAppearanceDebounced = useCallback(
(patch: AppearancePatch) => {
pendingDebouncedPatchRef.current = {
...pendingDebouncedPatchRef.current,
...patch,
};
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
debounceTimerRef.current = null;
}, 500);
},
[persistAppearance],
);
useEffect(
() => () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
pendingDebouncedPatchRef.current = {};
},
[],
);
const updateAppearanceDebounced = useCallback((patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
}, []);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
updateAppearance,
updateAppearanceDebounced,
isUpdating: updateMutation.isPending,
isUpdating: false,
}),
[
appearance,
updateAppearance,
updateAppearanceDebounced,
updateMutation.isPending,
],
[appearance, updateAppearance, updateAppearanceDebounced],
);
return (
@@ -0,0 +1,17 @@
"use client";
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
import { AppearanceProvider } from "~/components/providers/appearance-provider";
/** Client providers for public/marketing pages — no tRPC or DB-backed settings sync. */
export function MarketingProviders({
children,
}: {
children: React.ReactNode;
}) {
return (
<AppearanceProvider>
<AnimationPreferencesProvider>{children}</AnimationPreferencesProvider>
</AppearanceProvider>
);
}
+11 -4
View File
@@ -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()],
});
+27
View File
@@ -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());
}
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
}
};
/**