From 6ec26a4a0d6a3baf6793655ea6cabb1b366ff516 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Fri, 26 Jun 2026 02:00:06 -0400 Subject: [PATCH] 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 --- .env.example | 163 +++++++--- Dockerfile | 11 +- README.md | 4 +- docker-compose.yml | 2 +- docs/ARCHITECTURE.md | 2 +- next.config.js | 4 +- src/app/(legal)/layout.tsx | 3 +- src/app/(marketing)/layout.tsx | 9 + src/app/(marketing)/page.tsx | 14 + src/app/auth/layout.tsx | 9 + src/app/auth/signin/page.tsx | 8 +- src/app/auth/signin/signin-form.tsx | 246 +++++++-------- src/app/dashboard/layout.tsx | 17 +- src/app/dashboard/page.tsx | 7 +- src/app/i/layout.tsx | 17 + src/app/layout.tsx | 21 +- src/app/page.tsx | 110 ------- src/components/auth/auth-page-shell.tsx | 71 +++++ src/components/layout/brand-background.tsx | 9 + src/components/legal/legal-document.tsx | 16 +- src/components/legal/legal-page-shell.tsx | 66 ++-- src/components/marketing/app-screenshots.tsx | 265 ++++++++++++++++ src/components/marketing/browser-frame.tsx | 32 ++ src/components/marketing/landing-page.tsx | 235 ++++++++++++++ src/components/marketing/marketing-chrome.tsx | 84 +++++ .../animation-preferences-provider-synced.tsx | 235 ++++++++++++++ .../animation-preferences-provider.tsx | 296 +++--------------- src/components/providers/app-providers.tsx | 18 ++ .../providers/appearance-provider-synced.tsx | 237 ++++++++++++++ .../providers/appearance-provider.tsx | 216 ++----------- .../providers/marketing-providers.tsx | 17 + src/lib/auth-client.ts | 15 +- src/lib/auth-server.ts | 27 ++ src/lib/public-routes.ts | 21 ++ src/proxy.ts | 12 +- src/server/api/trpc.ts | 43 ++- 36 files changed, 1743 insertions(+), 819 deletions(-) create mode 100644 src/app/(marketing)/layout.tsx create mode 100644 src/app/(marketing)/page.tsx create mode 100644 src/app/auth/layout.tsx create mode 100644 src/app/i/layout.tsx delete mode 100644 src/app/page.tsx create mode 100644 src/components/auth/auth-page-shell.tsx create mode 100644 src/components/layout/brand-background.tsx create mode 100644 src/components/marketing/app-screenshots.tsx create mode 100644 src/components/marketing/browser-frame.tsx create mode 100644 src/components/marketing/landing-page.tsx create mode 100644 src/components/marketing/marketing-chrome.tsx create mode 100644 src/components/providers/animation-preferences-provider-synced.tsx create mode 100644 src/components/providers/app-providers.tsx create mode 100644 src/components/providers/appearance-provider-synced.tsx create mode 100644 src/components/providers/marketing-providers.tsx create mode 100644 src/lib/auth-server.ts create mode 100644 src/lib/public-routes.ts diff --git a/.env.example b/.env.example index 481a6c9..7c751d5 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index b30892f..4c29e43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/README.md b/README.md index 5cf99a2..97183b4 100644 --- a/README.md +++ b/README.md @@ -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 **3–4GB 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 diff --git a/docker-compose.yml b/docker-compose.yml index f77a5aa..c8bed66 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 40c3ffc..a3e758d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/next.config.js b/next.config.js index 5a46c89..eeef5bf 100644 --- a/next.config.js +++ b/next.config.js @@ -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 }, } : {}), }; diff --git a/src/app/(legal)/layout.tsx b/src/app/(legal)/layout.tsx index 812bb12..b9f6005 100644 --- a/src/app/(legal)/layout.tsx +++ b/src/app/(legal)/layout.tsx @@ -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 {children}; } diff --git a/src/app/(marketing)/layout.tsx b/src/app/(marketing)/layout.tsx new file mode 100644 index 0000000..18ceb12 --- /dev/null +++ b/src/app/(marketing)/layout.tsx @@ -0,0 +1,9 @@ +import { MarketingProviders } from "~/components/providers/marketing-providers"; + +export default function MarketingLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/(marketing)/page.tsx b/src/app/(marketing)/page.tsx new file mode 100644 index 0000000..4b8e5fa --- /dev/null +++ b/src/app/(marketing)/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx new file mode 100644 index 0000000..f300b8f --- /dev/null +++ b/src/app/auth/layout.tsx @@ -0,0 +1,9 @@ +import { MarketingProviders } from "~/components/providers/marketing-providers"; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 138c61a..1d2b5ff 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -4,7 +4,13 @@ import { SignInForm } from "./signin-form"; export default function SignInPage() { return ( - Loading...}> + + Loading… + + } + > ); diff --git a/src/app/auth/signin/signin-form.tsx b/src/app/auth/signin/signin-form.tsx index ae9040f..0ee66f3 100644 --- a/src/app/auth/signin/signin-form.tsx +++ b/src/app/auth/signin/signin-form.tsx @@ -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 ( -
-
-
-
-
+ + + - - -
-
- -
-

Welcome back

-

Sign in to your account

+ {signupDisabled && ( +

+ New account registration is currently disabled. +

+ )} + + {authentikEnabled && ( +
+ +
+
+ +
+
+ + or +
- - {signupDisabled && ( -
- New account registration is currently disabled. -
- )} - - {authentikEnabled && ( -
- -
-
- -
-
- or -
-
-
- )} - -
-
- -
- - setEmail(e.target.value)} - required - autoFocus - className="h-10 pl-10" - placeholder="you@example.com" - /> -
-
- -
-
- - - Forgot password? - -
-
- - setPassword(e.target.value)} - required - className="h-10 pl-10" - placeholder="••••••••" - /> -
-
- - -
- - {allowRegistration && ( -

- Don't have an account?{" "} - - Sign up - -

- )} - -
- - -
- ); -} + )} -export default function SignInPageClient() { - return ( - Loading...
}> - - +
+
+ +
+ + setEmail(e.target.value)} + required + autoFocus + autoComplete="email" + className="h-11 pl-10" + placeholder="you@example.com" + /> +
+
+ +
+
+ + + Forgot password? + +
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + className="h-11 pl-10" + placeholder="••••••••" + /> +
+
+ + +
+ + {allowRegistration && !signupDisabled && ( +

+ Don't have an account?{" "} + + Create account + +

+ )} + + + + ); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index add294f..c3257f5 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -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 {children}; + const session = await getOptionalServerSessionFromHeaders(); + + if (!session?.user) { + redirect("/auth/signin?callbackUrl=/dashboard"); + } + + return ( + + {children} + + ); } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 86635d7..1f4ad7b 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -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 diff --git a/src/app/i/layout.tsx b/src/app/i/layout.tsx new file mode 100644 index 0000000..1eba287 --- /dev/null +++ b/src/app/i/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c67eb91..dfa2377 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ /> -
-
-
-
+ - - - -
{children}
-
-
- - -
+
{children}
+ + ); diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index 875f035..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -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 ( -
- - -
-
- - -
- -
-
-
-

- Personal invoicing -

-

- {brand.name} is a place to make and track invoices. -

-

- Built for one person managing real clients, real work, and the - small admin loop around getting paid. -

-
- -
- - - - {allowRegistration && ( - - - - )} -
-
- -
-
-
-
- -
-
-

Clients

-

- Keep the people and businesses you invoice in one place. -

-
-
- -
-
- -
-
-

Invoices

-

- Draft, send, mark paid, and export the PDF when you need it. -

-
-
-
-
-
- -
- © 2026 {brand.name} -
- - Privacy Policy - - - Terms of Service - -
-
-
-
- ); -} diff --git a/src/components/auth/auth-page-shell.tsx b/src/components/auth/auth-page-shell.tsx new file mode 100644 index 0000000..b907da4 --- /dev/null +++ b/src/components/auth/auth-page-shell.tsx @@ -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 ( +
+
+ + + Back to home + + {children} +
+
+ ); +} + +export function AuthCard({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function AuthCardHeader({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
+ +
+

+ {title} +

+

{description}

+
+
+ ); +} diff --git a/src/components/layout/brand-background.tsx b/src/components/layout/brand-background.tsx new file mode 100644 index 0000000..24364b3 --- /dev/null +++ b/src/components/layout/brand-background.tsx @@ -0,0 +1,9 @@ +/** Grid + animated blob backdrop shared by the app shell and marketing pages. */ +export function BrandBackground() { + return ( +
+
+
+
+ ); +} diff --git a/src/components/legal/legal-document.tsx b/src/components/legal/legal-document.tsx index 6a83e2f..6043b4e 100644 --- a/src/components/legal/legal-document.tsx +++ b/src/components/legal/legal-document.tsx @@ -66,7 +66,7 @@ function LegalSectionBlock({ return (

@@ -82,12 +82,20 @@ function LegalSectionBlock({ export function LegalDocument({ sections }: { sections: LegalSection[] }) { return ( -
-