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:
+124
-39
@@ -1,68 +1,153 @@
|
|||||||
# beenvoice-web environment
|
# =============================================================================
|
||||||
|
# beenvoice-web — environment template
|
||||||
|
# =============================================================================
|
||||||
#
|
#
|
||||||
# Local dev: cp .env.example .env.local
|
# Quick start (local dev):
|
||||||
# Docker: cp .env.example .env
|
# 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:
|
# Quick start (Docker app + Postgres):
|
||||||
# 1. Set AUTH_SECRET, BETTER_AUTH_URL, NEXT_PUBLIC_APP_URL to your public URL
|
# cp .env.example .env
|
||||||
# 2. docker compose build --no-cache app # NEXT_PUBLIC_* is baked in at build
|
# # edit AUTH_SECRET + public URLs below
|
||||||
# 3. docker compose up -d --build # migrations run on app container start
|
# 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
|
# Core — required
|
||||||
WEB_PORT=3000
|
# =============================================================================
|
||||||
|
|
||||||
# Auth
|
# PostgreSQL connection string.
|
||||||
# Generate with: openssl rand -base64 32
|
# 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
|
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
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
# Public app URL — baked into the client bundle at Docker build time.
|
# Same as BETTER_AUTH_URL in most setups. Embedded in the client bundle at build.
|
||||||
# Set this to the same value as BETTER_AUTH_URL before `docker compose build`.
|
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
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_USER=postgres
|
||||||
POSTGRES_PASSWORD=postgres
|
POSTGRES_PASSWORD=postgres
|
||||||
POSTGRES_DB=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.
|
# White-label defaults (optional)
|
||||||
NEXT_PUBLIC_BRAND_NAME="beenvoice"
|
# =============================================================================
|
||||||
NEXT_PUBLIC_BRAND_TAGLINE="Simple and efficient invoicing for freelancers and small businesses"
|
# Baked in at Docker build. After first deploy, admins can override many of
|
||||||
NEXT_PUBLIC_BRAND_LOGO_TEXT="beenvoice"
|
# these from Settings → Appearance in the dashboard.
|
||||||
NEXT_PUBLIC_BRAND_ICON="$"
|
|
||||||
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME="beenvoice"
|
NEXT_PUBLIC_BRAND_NAME=beenvoice
|
||||||
NEXT_PUBLIC_DEFAULT_FONT="brand"
|
NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses
|
||||||
NEXT_PUBLIC_DEFAULT_BODY_FONT="brand"
|
NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
|
||||||
NEXT_PUBLIC_DEFAULT_HEADING_FONT="brand"
|
NEXT_PUBLIC_BRAND_ICON=$
|
||||||
NEXT_PUBLIC_DEFAULT_RADIUS="xl"
|
|
||||||
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE="floating"
|
# 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_API_KEY=
|
||||||
RESEND_DOMAIN=
|
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_WEBSITE_ID=
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js
|
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
|
# 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
|
NEXT_PUBLIC_AUTHENTIK_ENABLED=false
|
||||||
AUTHENTIK_ISSUER=
|
AUTHENTIK_ISSUER=
|
||||||
AUTHENTIK_CLIENT_ID=
|
AUTHENTIK_CLIENT_ID=
|
||||||
AUTHENTIK_CLIENT_SECRET=
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
|
# Optional extra trusted origin for better-auth (defaults derived from issuer).
|
||||||
AUTHENTIK_ORIGIN=
|
AUTHENTIK_ORIGIN=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Advanced / CI (usually unset)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Skip Zod env validation during `next build` (set automatically in Dockerfile).
|
||||||
|
# SKIP_ENV_VALIDATION=1
|
||||||
|
|||||||
+6
-5
@@ -6,7 +6,10 @@ FROM base AS install
|
|||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lock ./
|
||||||
RUN bun install --frozen-lockfile
|
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 --from=install /usr/src/app/node_modules node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
@@ -15,19 +18,17 @@ ARG BETTER_AUTH_URL=http://localhost:3000
|
|||||||
|
|
||||||
# Low-memory Docker build profile:
|
# Low-memory Docker build profile:
|
||||||
# - disable React Compiler (saves compile RAM; prod image still runs fine without it)
|
# - 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)
|
# - skip tsc inside `next build` (run `bun run check` in CI instead)
|
||||||
# - cap Node heap at 2GB (raise Docker/Colima memory if this still OOMs)
|
|
||||||
ENV DOCKER_BUILD=1 \
|
ENV DOCKER_BUILD=1 \
|
||||||
DISABLE_REACT_COMPILER=1 \
|
DISABLE_REACT_COMPILER=1 \
|
||||||
NODE_ENV=production \
|
NODE_ENV=production \
|
||||||
SKIP_ENV_VALIDATION=1 \
|
SKIP_ENV_VALIDATION=1 \
|
||||||
NEXT_TELEMETRY_DISABLED=1 \
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
NODE_OPTIONS=--max-old-space-size=2048 \
|
|
||||||
BETTER_AUTH_URL=${BETTER_AUTH_URL} \
|
BETTER_AUTH_URL=${BETTER_AUTH_URL} \
|
||||||
NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} \
|
NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} \
|
||||||
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
|
AUTH_SECRET=docker-build-placeholder-secret-do-not-use \
|
||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
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
|
FROM base AS release
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
|||||||
@@ -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.
|
**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
|
### 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`.
|
`--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
|
### 3. Updating an existing deploy
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ services:
|
|||||||
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
||||||
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
|
AUTHENTIK_ORIGIN: ${AUTHENTIK_ORIGIN:-}
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-3000}:3000"
|
- "${WEB_PORT:-${PORT:-3000}}:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -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.yml` | Deploy: `app` + `db` (Postgres internal); copy `.env.example` → `.env` |
|
||||||
| `docker-compose.dev.yml` | Local dev: Postgres only, port `${POSTGRES_PORT:-5432}` |
|
| `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.
|
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
@@ -16,11 +16,11 @@ const config = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
webpackMemoryOptimizations: true,
|
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
|
...(isDockerBuild
|
||||||
? {
|
? {
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
eslint: { ignoreDuringBuilds: true },
|
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||||
import { brand } from "~/lib/branding";
|
import { brand } from "~/lib/branding";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -14,5 +15,5 @@ export default function LegalLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return children;
|
return <MarketingProviders>{children}</MarketingProviders>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||||
|
|
||||||
|
export default function MarketingLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <MarketingProviders>{children}</MarketingProviders>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { LandingPage } from "~/components/marketing/landing-page";
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const allowRegistration = env.DISABLE_SIGNUPS !== true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen">
|
||||||
|
<LandingPage allowRegistration={allowRegistration} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||||
|
|
||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <MarketingProviders>{children}</MarketingProviders>;
|
||||||
|
}
|
||||||
@@ -4,7 +4,13 @@ import { SignInForm } from "./signin-form";
|
|||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
return (
|
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} />
|
<SignInForm allowRegistration={env.DISABLE_SIGNUPS !== true} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
+116
-130
@@ -1,17 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, Suspense } from "react";
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { authClient } from "~/lib/auth-client";
|
import { ArrowRight, Lock, Mail, Shield } from "lucide-react";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import {
|
||||||
import { Input } from "~/components/ui/input";
|
AuthCard,
|
||||||
import { Button } from "~/components/ui/button";
|
AuthCardHeader,
|
||||||
import { Label } from "~/components/ui/label";
|
AuthPageShell,
|
||||||
import { toast } from "sonner";
|
} from "~/components/auth/auth-page-shell";
|
||||||
import { Logo } from "~/components/branding/logo";
|
|
||||||
import { LegalAgreementNotice } from "~/components/legal/legal-links";
|
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 { env } from "~/env";
|
||||||
import { Mail, Lock, ArrowRight, Shield } from "lucide-react";
|
import { authClient } from "~/lib/auth-client";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface SignInFormProps {
|
interface SignInFormProps {
|
||||||
allowRegistration: boolean;
|
allowRegistration: boolean;
|
||||||
@@ -41,11 +45,12 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
|||||||
? error.message
|
? error.message
|
||||||
: "Invalid email or password",
|
: "Invalid email or password",
|
||||||
);
|
);
|
||||||
} else {
|
return;
|
||||||
toast.success("Signed in successfully!");
|
|
||||||
router.push(callbackUrl);
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success("Signed in successfully!");
|
||||||
|
router.push(callbackUrl);
|
||||||
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSocialSignIn() {
|
async function handleSocialSignIn() {
|
||||||
@@ -62,127 +67,108 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
|
<AuthPageShell>
|
||||||
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
|
<AuthCard>
|
||||||
<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>
|
<AuthCardHeader
|
||||||
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
|
title="Welcome back"
|
||||||
</div>
|
description="Sign in to your workspace"
|
||||||
|
/>
|
||||||
|
|
||||||
<Card className="mx-auto w-full max-w-md border-border/50 bg-background/80 backdrop-blur-xl">
|
{signupDisabled && (
|
||||||
<CardContent className="p-8">
|
<p className="bg-muted/50 text-muted-foreground mb-5 rounded-xl border px-3 py-2.5 text-sm">
|
||||||
<div className="space-y-6">
|
New account registration is currently disabled.
|
||||||
<div className="space-y-2">
|
</p>
|
||||||
<Logo size="lg" />
|
)}
|
||||||
<div>
|
|
||||||
<h1 className="font-heading text-2xl font-bold">Welcome back</h1>
|
{authentikEnabled && (
|
||||||
<p className="text-muted-foreground text-sm">Sign in to your account</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{signupDisabled && (
|
|
||||||
<div className="border-border bg-muted/50 text-muted-foreground rounded-lg border px-3 py-2 text-sm">
|
|
||||||
New account registration is currently disabled.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{authentikEnabled && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
className="h-10 w-full"
|
|
||||||
onClick={handleSocialSignIn}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<Shield className="mr-2 h-4 w-4" />
|
|
||||||
Sign in with Authentik
|
|
||||||
</Button>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="border-border/50 w-full border-t" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background text-muted-foreground px-2">or</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSignIn} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
className="h-10 pl-10"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<a href="/auth/forgot-password" className="text-muted-foreground text-xs hover:underline">
|
|
||||||
Forgot password?
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
className="h-10 pl-10"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="h-10 w-full" disabled={loading}>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="border-primary-foreground/30 border-t-primary-foreground h-4 w-4 animate-spin rounded-full border-2" />
|
|
||||||
<span>Signing in…</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span>Sign In</span>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{allowRegistration && (
|
|
||||||
<p className="text-muted-foreground text-center text-sm">
|
|
||||||
Don't have an account?{" "}
|
|
||||||
<a href="/auth/register" className="text-foreground font-medium hover:underline">
|
|
||||||
Sign up
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<LegalAgreementNotice action="signing in" />
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SignInPageClient() {
|
<form onSubmit={handleSignIn} className="space-y-4">
|
||||||
return (
|
<div className="space-y-2">
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Label htmlFor="email">Email</Label>
|
||||||
<SignInForm allowRegistration />
|
<div className="relative">
|
||||||
</Suspense>
|
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="email"
|
||||||
|
className="h-11 pl-10"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Link
|
||||||
|
href="/auth/forgot-password"
|
||||||
|
className="text-muted-foreground text-xs hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="h-11 pl-10"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="h-11 w-full" disabled={loading}>
|
||||||
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
|
{!loading && <ArrowRight className="ml-2 h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{allowRegistration && !signupDisabled && (
|
||||||
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/auth/register"
|
||||||
|
className="text-foreground font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LegalAgreementNotice action="signing in" className="mt-5" />
|
||||||
|
</AuthCard>
|
||||||
|
</AuthPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AppProviders } from "~/components/providers/app-providers";
|
||||||
import { DashboardShell } from "~/components/layout/dashboard-shell";
|
import { DashboardShell } from "~/components/layout/dashboard-shell";
|
||||||
|
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return <DashboardShell>{children}</DashboardShell>;
|
const session = await getOptionalServerSessionFromHeaders();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/auth/signin?callbackUrl=/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppProviders>
|
||||||
|
<DashboardShell>{children}</DashboardShell>
|
||||||
|
</AppProviders>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import { auth } from "~/lib/auth";
|
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { HydrateClient, api } from "~/trpc/server";
|
import { HydrateClient, api } from "~/trpc/server";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
import { RevenueChart, InvoiceStatusChart, MonthlyMetricsChart } from "~/app/dashboard/_components/charts-client";
|
import { RevenueChart, InvoiceStatusChart, MonthlyMetricsChart } from "~/app/dashboard/_components/charts-client";
|
||||||
@@ -494,9 +493,7 @@ import { DashboardPageHeader } from "~/components/layout/page-header";
|
|||||||
// ... imports
|
// ... imports
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await auth.api.getSession({
|
const session = await getOptionalServerSessionFromHeaders();
|
||||||
headers: await headers(),
|
|
||||||
});
|
|
||||||
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
|
||||||
|
|
||||||
// Fetch stats centrally
|
// Fetch stats centrally
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MarketingProviders } from "~/components/providers/marketing-providers";
|
||||||
|
import { TRPCReactProvider } from "~/trpc/react";
|
||||||
|
|
||||||
|
/** Public invoice links need tRPC but not authenticated settings sync. */
|
||||||
|
export default function PublicInvoiceLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TRPCReactProvider>
|
||||||
|
<MarketingProviders>{children}</MarketingProviders>
|
||||||
|
</TRPCReactProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
+5
-16
@@ -3,10 +3,7 @@ import "~/styles/globals.css";
|
|||||||
import { type Metadata } from "next";
|
import { type Metadata } from "next";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
|
||||||
import { TRPCReactProvider } from "~/trpc/react";
|
|
||||||
import { Toaster } from "~/components/ui/sonner";
|
import { Toaster } from "~/components/ui/sonner";
|
||||||
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
|
|
||||||
import { AppearanceProvider } from "~/components/providers/appearance-provider";
|
|
||||||
import {
|
import {
|
||||||
brand,
|
brand,
|
||||||
defaultBodyFontPreference,
|
defaultBodyFontPreference,
|
||||||
@@ -17,6 +14,7 @@ import {
|
|||||||
} from "~/lib/branding";
|
} from "~/lib/branding";
|
||||||
|
|
||||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||||
|
import { BrandBackground } from "~/components/layout/brand-background";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `${brand.name} - Invoicing Made Simple`,
|
title: `${brand.name} - Invoicing Made Simple`,
|
||||||
@@ -108,20 +106,11 @@ export default function RootLayout({
|
|||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
<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">
|
<BrandBackground />
|
||||||
<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>
|
|
||||||
|
|
||||||
<TRPCReactProvider>
|
<div className="relative z-10">{children}</div>
|
||||||
<AppearanceProvider>
|
<Toaster />
|
||||||
<AnimationPreferencesProvider>
|
<UmamiScript />
|
||||||
<div className="relative z-10">{children}</div>
|
|
||||||
</AnimationPreferencesProvider>
|
|
||||||
</AppearanceProvider>
|
|
||||||
<Toaster />
|
|
||||||
<UmamiScript />
|
|
||||||
</TRPCReactProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { ArrowRight, FileText, UserRound } from "lucide-react";
|
|
||||||
import { AuthRedirect } from "~/components/AuthRedirect";
|
|
||||||
import { Logo } from "~/components/branding/logo";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { brand } from "~/lib/branding";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
const allowRegistration = env.DISABLE_SIGNUPS !== true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="bg-background text-foreground min-h-screen">
|
|
||||||
<AuthRedirect />
|
|
||||||
|
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-5 py-5 sm:px-6 lg:px-8">
|
|
||||||
<header className="flex items-center justify-between gap-4 border-b py-4">
|
|
||||||
<Logo animated={false} />
|
|
||||||
<nav className="flex items-center gap-2">
|
|
||||||
<Link href="/auth/signin">
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
{allowRegistration && (
|
|
||||||
<Link href="/auth/register">
|
|
||||||
<Button size="sm">Create account</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="grid flex-1 items-center gap-10 py-14 md:grid-cols-[1fr_320px] md:py-20">
|
|
||||||
<div className="max-w-2xl space-y-7">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
|
||||||
Personal invoicing
|
|
||||||
</p>
|
|
||||||
<h1 className="font-heading text-4xl leading-tight font-bold tracking-normal sm:text-5xl">
|
|
||||||
{brand.name} is a place to make and track invoices.
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground max-w-xl text-base leading-7 sm:text-lg">
|
|
||||||
Built for one person managing real clients, real work, and the
|
|
||||||
small admin loop around getting paid.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
|
||||||
<Link href="/auth/signin">
|
|
||||||
<Button size="lg" className="h-11 px-5">
|
|
||||||
Open workspace
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
{allowRegistration && (
|
|
||||||
<Link href="/auth/register">
|
|
||||||
<Button variant="outline" size="lg" className="h-11 px-5">
|
|
||||||
Create account
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-border bg-card text-card-foreground rounded-xl border p-5 shadow-sm">
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="bg-primary/10 text-primary rounded-md p-2">
|
|
||||||
<UserRound className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold">Clients</h2>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm leading-6">
|
|
||||||
Keep the people and businesses you invoice in one place.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3 border-t pt-5">
|
|
||||||
<div className="bg-primary/10 text-primary rounded-md p-2">
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold">Invoices</h2>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm leading-6">
|
|
||||||
Draft, send, mark paid, and export the PDF when you need it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer className="text-muted-foreground flex flex-col gap-3 border-t py-5 text-sm sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<span>© 2026 {brand.name}</span>
|
|
||||||
<div className="flex gap-5">
|
|
||||||
<Link href="/privacy" className="hover:text-foreground">
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
<Link href="/terms" className="hover:text-foreground">
|
|
||||||
Terms of Service
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Logo } from "~/components/branding/logo";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export function AuthPageShell({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-dashboard text-foreground flex min-h-screen flex-col px-5 py-6 sm:px-6 sm:py-8">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mx-auto flex w-full max-w-md flex-1 flex-col justify-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-muted-foreground hover:text-foreground mb-6 inline-flex items-center gap-2 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to home
|
||||||
|
</Link>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthCard({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border/50 bg-background/80 rounded-3xl border p-6 shadow-xl backdrop-blur-xl sm:p-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthCardHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 space-y-3">
|
||||||
|
<Logo size="md" animated={false} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="font-heading text-2xl font-semibold tracking-tight">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Grid + animated blob backdrop shared by the app shell and marketing pages. */
|
||||||
|
export function BrandBackground() {
|
||||||
|
return (
|
||||||
|
<div className="brand-background pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]" />
|
||||||
|
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ function LegalSectionBlock({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id={section.id}
|
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">
|
<div className="px-6 pt-8 pb-4 sm:px-8">
|
||||||
<h2 className="text-foreground text-lg font-semibold tracking-tight sm:text-xl">
|
<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[] }) {
|
export function LegalDocument({ sections }: { sections: LegalSection[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)] lg:items-start">
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)] lg:items-start lg:gap-8">
|
||||||
<aside className="bg-card border-border rounded-lg border p-4 lg:sticky lg:top-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} />
|
<LegalTableOfContents sections={sections} />
|
||||||
</aside>
|
</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) => (
|
{sections.map((section, index) => (
|
||||||
<LegalSectionBlock
|
<LegalSectionBlock
|
||||||
key={section.id}
|
key={section.id}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import Link from "next/link";
|
import {
|
||||||
import { ArrowLeft } from "lucide-react";
|
MarketingFooter,
|
||||||
|
MarketingHeader,
|
||||||
import { Button } from "~/components/ui/button";
|
MarketingPageShell,
|
||||||
import { Logo } from "~/components/branding/logo";
|
marketingSurfaceClass,
|
||||||
|
} from "~/components/marketing/marketing-chrome";
|
||||||
import { LEGAL_LAST_UPDATED } from "~/lib/legal";
|
import { LEGAL_LAST_UPDATED } from "~/lib/legal";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
type LegalPageShellProps = {
|
type LegalPageShellProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -16,35 +19,34 @@ export function LegalPageShell({
|
|||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
}: LegalPageShellProps) {
|
}: LegalPageShellProps) {
|
||||||
|
const allowRegistration = env.DISABLE_SIGNUPS !== true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<MarketingPageShell>
|
||||||
<header className="border-border bg-card/80 border-b backdrop-blur-sm">
|
<MarketingHeader allowRegistration={allowRegistration} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="max-w-3xl space-y-2">
|
<div
|
||||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">{title}</h1>
|
className={cn(
|
||||||
{description ? (
|
marketingSurfaceClass,
|
||||||
<p className="text-muted-foreground text-base leading-relaxed">{description}</p>
|
"mb-8 space-y-3 px-6 py-8 sm:px-8 sm:py-10",
|
||||||
) : null}
|
)}
|
||||||
<p className="text-muted-foreground text-sm">
|
>
|
||||||
Last updated {LEGAL_LAST_UPDATED}
|
<h1 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||||
</p>
|
{title}
|
||||||
</div>
|
</h1>
|
||||||
</div>
|
{description ? (
|
||||||
</header>
|
<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">
|
<main>{children}</main>
|
||||||
{children}
|
|
||||||
</main>
|
<MarketingFooter />
|
||||||
</div>
|
</MarketingPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
Receipt,
|
||||||
|
Settings,
|
||||||
|
Timer,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { BrowserFrame } from "~/components/marketing/browser-frame";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
function MockSidebar({ active }: { active: "dashboard" | "invoices" | "time" }) {
|
||||||
|
const items = [
|
||||||
|
{ id: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ id: "invoices" as const, label: "Invoices", icon: FileText },
|
||||||
|
{ id: "time" as const, label: "Time clock", icon: Timer },
|
||||||
|
{ id: "clients" as const, label: "Clients", icon: Users },
|
||||||
|
{ id: "expenses" as const, label: "Expenses", icon: Receipt },
|
||||||
|
{ id: "reports" as const, label: "Reports", icon: BarChart3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="bg-card/90 hidden w-36 shrink-0 border-r p-3 sm:block">
|
||||||
|
<div className="text-primary mb-4 font-mono text-xs font-bold">$ beenvoice</div>
|
||||||
|
<nav className="space-y-0.5">
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = item.id === active;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-lg px-2 py-1.5 text-[10px] font-medium",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="text-muted-foreground mt-6 flex items-center gap-2 px-2 text-[10px]">
|
||||||
|
<Settings className="h-3 w-3" />
|
||||||
|
Settings
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({
|
||||||
|
children,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
tone: "draft" | "sent" | "paid" | "overdue";
|
||||||
|
}) {
|
||||||
|
const tones = {
|
||||||
|
draft: "bg-muted text-muted-foreground",
|
||||||
|
sent: "bg-blue-500/10 text-blue-700 dark:text-blue-300",
|
||||||
|
paid: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||||
|
overdue: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex rounded-full px-2 py-0.5 text-[9px] font-medium",
|
||||||
|
tones[tone],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoicesScreenshot({ className }: { className?: string }) {
|
||||||
|
const rows = [
|
||||||
|
{ client: "Northwind Studio", id: "INV-1042", amount: "$1,850.00", status: "sent" as const },
|
||||||
|
{ client: "Harbor & Co.", id: "INV-1041", amount: "$640.00", status: "paid" as const },
|
||||||
|
{ client: "Lumen Creative", id: "INV-1040", amount: "$2,100.00", status: "draft" as const },
|
||||||
|
{ client: "Field Notes Ltd", id: "INV-1039", amount: "$420.00", status: "overdue" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserFrame className={className} url="beenvoice.app/dashboard/invoices">
|
||||||
|
<div className="flex min-h-[280px] sm:min-h-[320px]">
|
||||||
|
<MockSidebar active="invoices" />
|
||||||
|
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-sm font-semibold sm:text-base">
|
||||||
|
Invoices
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||||
|
Draft, send, and track what you're owed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-primary text-primary-foreground rounded-lg px-2.5 py-1 text-[10px] font-medium">
|
||||||
|
New invoice
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card overflow-hidden rounded-xl border">
|
||||||
|
<div className="text-muted-foreground grid grid-cols-[1fr_auto_auto] gap-2 border-b px-3 py-2 text-[9px] font-medium uppercase tracking-wide sm:grid-cols-[1.2fr_0.8fr_auto_auto] sm:px-4">
|
||||||
|
<span>Client</span>
|
||||||
|
<span className="hidden sm:block">Invoice</span>
|
||||||
|
<span>Amount</span>
|
||||||
|
<span>Status</span>
|
||||||
|
</div>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
className="grid grid-cols-[1fr_auto_auto] items-center gap-2 border-b px-3 py-2.5 text-[10px] last:border-0 sm:grid-cols-[1.2fr_0.8fr_auto_auto] sm:px-4 sm:text-xs"
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">{row.client}</span>
|
||||||
|
<span className="text-muted-foreground hidden sm:block">{row.id}</span>
|
||||||
|
<span className="tabular-nums">{row.amount}</span>
|
||||||
|
<StatusBadge tone={row.status}>
|
||||||
|
{row.status.charAt(0).toUpperCase() + row.status.slice(1)}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeClockScreenshot({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<BrowserFrame className={className} url="beenvoice.app/dashboard/time-clock">
|
||||||
|
<div className="flex min-h-[260px] sm:min-h-[300px]">
|
||||||
|
<MockSidebar active="time" />
|
||||||
|
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="font-heading text-sm font-semibold sm:text-base">
|
||||||
|
Time clock
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||||
|
Track billable hours and roll them into invoices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="bg-card rounded-xl border p-4">
|
||||||
|
<div className="text-muted-foreground mb-2 flex items-center gap-1.5 text-[10px] font-medium">
|
||||||
|
<Timer className="h-3 w-3" />
|
||||||
|
Active session
|
||||||
|
</div>
|
||||||
|
<div className="font-heading text-2xl font-semibold tabular-nums sm:text-3xl">
|
||||||
|
02:14:38
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
Brand refresh — Northwind Studio
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<div className="bg-primary text-primary-foreground rounded-lg px-3 py-1.5 text-[10px] font-medium">
|
||||||
|
Stop
|
||||||
|
</div>
|
||||||
|
<div className="border-border rounded-lg border px-3 py-1.5 text-[10px] font-medium">
|
||||||
|
Pause
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card rounded-xl border p-4">
|
||||||
|
<div className="text-muted-foreground mb-3 flex items-center gap-1.5 text-[10px] font-medium">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Recent entries
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{[
|
||||||
|
{ label: "Wireframes", time: "1h 20m", client: "Harbor & Co." },
|
||||||
|
{ label: "Copy edits", time: "45m", client: "Lumen Creative" },
|
||||||
|
{ label: "Kickoff call", time: "30m", client: "Field Notes Ltd" },
|
||||||
|
].map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.label}
|
||||||
|
className="flex items-center justify-between gap-2 text-[10px] sm:text-xs"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{entry.label}</p>
|
||||||
|
<p className="text-muted-foreground truncate">{entry.client}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground shrink-0 tabular-nums">
|
||||||
|
{entry.time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardScreenshot({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<BrowserFrame className={className} url="beenvoice.app/dashboard">
|
||||||
|
<div className="flex min-h-[260px] sm:min-h-[300px]">
|
||||||
|
<MockSidebar active="dashboard" />
|
||||||
|
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="font-heading text-sm font-semibold sm:text-base">
|
||||||
|
Good afternoon
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-[10px] sm:text-xs">
|
||||||
|
Here's what needs your attention.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="bg-card rounded-xl border p-4">
|
||||||
|
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
|
||||||
|
Awaiting payment
|
||||||
|
</p>
|
||||||
|
<p className="font-heading mt-2 text-lg font-semibold">3 invoices</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
Follow up on sent invoices when you're ready.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card rounded-xl border p-4">
|
||||||
|
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
|
||||||
|
Timer running
|
||||||
|
</p>
|
||||||
|
<p className="font-heading mt-2 text-lg font-semibold tabular-nums">
|
||||||
|
02:14:38
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
Northwind Studio · Brand refresh
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card mt-3 rounded-xl border p-4">
|
||||||
|
<p className="mb-3 text-[10px] font-medium">Recent activity</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
"Invoice INV-1042 sent to Northwind Studio",
|
||||||
|
"Timer started for Brand refresh",
|
||||||
|
"Client Harbor & Co. updated",
|
||||||
|
].map((line) => (
|
||||||
|
<div
|
||||||
|
key={line}
|
||||||
|
className="text-muted-foreground flex items-center gap-2 text-[10px] sm:text-xs"
|
||||||
|
>
|
||||||
|
<span className="bg-primary/60 h-1.5 w-1.5 shrink-0 rounded-full" />
|
||||||
|
<span className="truncate">{line}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export function BrowserFrame({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
url = "beenvoice.app/dashboard",
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
url?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border/80 bg-card overflow-hidden rounded-2xl border shadow-2xl shadow-black/8 ring-1 ring-black/5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-muted/50 flex items-center gap-3 border-b px-4 py-2.5">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f57]" />
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[#febc2e]" />
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[#28c840]" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-background/70 text-muted-foreground mx-auto flex h-7 w-full max-w-sm items-center justify-center rounded-lg px-3 text-[11px] tracking-wide">
|
||||||
|
{url}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-dashboard overflow-hidden">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
Mail,
|
||||||
|
Receipt,
|
||||||
|
Repeat,
|
||||||
|
Timer,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
DashboardScreenshot,
|
||||||
|
InvoicesScreenshot,
|
||||||
|
TimeClockScreenshot,
|
||||||
|
} from "~/components/marketing/app-screenshots";
|
||||||
|
import {
|
||||||
|
MarketingFooter,
|
||||||
|
MarketingHeader,
|
||||||
|
MarketingPageShell,
|
||||||
|
marketingSurfaceClass,
|
||||||
|
} from "~/components/marketing/marketing-chrome";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
title: "Invoices that stay organized",
|
||||||
|
description:
|
||||||
|
"Draft line items, apply taxes, send a link, and export a polished PDF when you need a file.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
title: "Clients and businesses",
|
||||||
|
description:
|
||||||
|
"Keep the people and companies you bill in one place, with the details you reuse on every invoice.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Timer,
|
||||||
|
title: "Built-in time clock",
|
||||||
|
description:
|
||||||
|
"Track billable hours as you work, then pull them straight into an invoice without retyping.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Repeat,
|
||||||
|
title: "Recurring invoices",
|
||||||
|
description:
|
||||||
|
"Set up retainers and subscriptions once, then let beenvoice generate the next invoice on schedule.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Receipt,
|
||||||
|
title: "Expenses",
|
||||||
|
description:
|
||||||
|
"Log costs alongside your work so nothing gets lost before you bill it out.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Mail,
|
||||||
|
title: "Send and follow up",
|
||||||
|
description:
|
||||||
|
"Email invoices from the app and keep status visible from draft through paid.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function FeatureRow({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
reverse = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
reverse?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid items-center gap-10 lg:grid-cols-2 lg:gap-16",
|
||||||
|
reverse && "lg:[&>*:first-child]:order-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground max-w-lg text-base leading-7">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LandingPage({ allowRegistration }: { allowRegistration: boolean }) {
|
||||||
|
return (
|
||||||
|
<MarketingPageShell>
|
||||||
|
<MarketingHeader allowRegistration={allowRegistration} />
|
||||||
|
|
||||||
|
<section className="pb-16 sm:pb-20 lg:pb-24">
|
||||||
|
<div className="mx-auto max-w-3xl text-center">
|
||||||
|
<p className="text-primary mb-4 text-sm font-medium tracking-wide uppercase">
|
||||||
|
Personal invoicing workspace
|
||||||
|
</p>
|
||||||
|
<h1 className="font-heading text-4xl leading-[1.1] font-bold tracking-tight sm:text-5xl lg:text-6xl">
|
||||||
|
Run your freelance admin from one place.
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-5 max-w-2xl text-base leading-7 sm:text-lg">
|
||||||
|
{brand.name} helps you manage clients, track time, send invoices,
|
||||||
|
and stay on top of getting paid — without the weight of a full
|
||||||
|
accounting suite.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||||
|
<Link href="/auth/signin">
|
||||||
|
<Button size="lg" className="h-11 px-6">
|
||||||
|
Open workspace
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{allowRegistration && (
|
||||||
|
<Link href="/auth/register">
|
||||||
|
<Button variant="outline" size="lg" className="h-11 px-6">
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mx-auto mt-14 max-w-5xl lg:mt-16">
|
||||||
|
<InvoicesScreenshot className="w-full" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-border/50 border-t py-16 sm:py-20">
|
||||||
|
<div className="mx-auto max-w-2xl text-center">
|
||||||
|
<h2 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||||
|
Everything around getting paid, in one flow
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground mt-4 text-base leading-7">
|
||||||
|
{brand.tagline}. Built for one person doing real client work — not
|
||||||
|
enterprise dashboards you'll never open.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{features.map((feature) => {
|
||||||
|
const Icon = feature.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={feature.title}
|
||||||
|
className="bg-card/70 hover:bg-card/90 border-border/60 rounded-2xl border p-5 backdrop-blur-sm transition-colors"
|
||||||
|
>
|
||||||
|
<div className="bg-primary/10 text-primary mb-4 inline-flex rounded-xl p-2.5">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold sm:text-base">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm leading-6">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-border/50 space-y-20 border-t py-16 sm:space-y-24 sm:py-20">
|
||||||
|
<FeatureRow
|
||||||
|
title="See your week at a glance"
|
||||||
|
description="Open the dashboard to check what's waiting on payment, whether a timer is still running, and what changed recently — without wading through reports."
|
||||||
|
>
|
||||||
|
<DashboardScreenshot />
|
||||||
|
</FeatureRow>
|
||||||
|
|
||||||
|
<FeatureRow
|
||||||
|
title="Track time where you already work"
|
||||||
|
description="Start a timer for the client and project you're on. When the work is done, turn those hours into invoice line items in a few clicks."
|
||||||
|
reverse
|
||||||
|
>
|
||||||
|
<TimeClockScreenshot />
|
||||||
|
</FeatureRow>
|
||||||
|
|
||||||
|
<FeatureRow
|
||||||
|
title="Invoices that look professional"
|
||||||
|
description="Clean layouts, PDF export, and a shareable link for clients. Mark invoices sent or paid as your pipeline moves."
|
||||||
|
>
|
||||||
|
<InvoicesScreenshot />
|
||||||
|
</FeatureRow>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
marketingSurfaceClass,
|
||||||
|
"bg-card/80 relative overflow-hidden px-6 py-10 text-center sm:px-10 sm:py-12",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="bg-primary/10 text-primary mx-auto mb-4 inline-flex rounded-full p-3">
|
||||||
|
<Clock className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<h2 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
Ready to simplify your invoicing?
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-3 max-w-xl text-sm leading-6 sm:text-base">
|
||||||
|
Sign in to your workspace or create an account to start with
|
||||||
|
clients, invoices, and time tracking in minutes.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||||
|
<Link href="/auth/signin">
|
||||||
|
<Button size="lg" className="h-11 px-6">
|
||||||
|
Open workspace
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{allowRegistration && (
|
||||||
|
<Link href="/auth/register">
|
||||||
|
<Button variant="outline" size="lg" className="h-11 px-6">
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MarketingFooter />
|
||||||
|
</MarketingPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Logo } from "~/components/branding/logo";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { brand } from "~/lib/branding";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export const marketingSurfaceClass =
|
||||||
|
"border-border/50 bg-background/80 rounded-3xl border shadow-xl backdrop-blur-xl";
|
||||||
|
|
||||||
|
export function MarketingPageShell({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-dashboard text-foreground relative min-h-screen">
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-5 pt-4 pb-6 sm:px-6 sm:pt-5 sm:pb-8 lg:px-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketingHeader({
|
||||||
|
allowRegistration = true,
|
||||||
|
sticky = true,
|
||||||
|
}: {
|
||||||
|
allowRegistration?: boolean;
|
||||||
|
sticky?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
marketingSurfaceClass,
|
||||||
|
"mb-8 flex items-center justify-between gap-4 px-4 py-3 sm:px-5",
|
||||||
|
sticky && "sticky top-4 z-20 sm:top-5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link href="/">
|
||||||
|
<Logo animated={false} />
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center gap-2">
|
||||||
|
<Link href="/auth/signin">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{allowRegistration && (
|
||||||
|
<Link href="/auth/register">
|
||||||
|
<Button size="sm">Create account</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketingFooter({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
className={cn(
|
||||||
|
marketingSurfaceClass,
|
||||||
|
"text-muted-foreground mt-8 flex flex-col gap-3 px-4 py-4 text-sm sm:flex-row sm:items-center sm:justify-between sm:px-5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>© 2026 {brand.name}</span>
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/terms"
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import {
|
||||||
|
AnimationPreferencesContext,
|
||||||
|
DEFAULT_PREFERS_REDUCED,
|
||||||
|
DEFAULT_SPEED,
|
||||||
|
applyPreferencesToDOM,
|
||||||
|
clampSpeed,
|
||||||
|
readLocalStorage,
|
||||||
|
writeLocalStorage,
|
||||||
|
type AnimationPreferencesContextValue,
|
||||||
|
type AnimationPreferencesProviderProps,
|
||||||
|
} from "~/components/providers/animation-preferences-provider";
|
||||||
|
|
||||||
|
type PartialPrefs = {
|
||||||
|
prefersReducedMotion?: boolean;
|
||||||
|
animationSpeedMultiplier?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dashboard animation preferences with tRPC sync. Must render inside TRPCReactProvider. */
|
||||||
|
export function AnimationPreferencesProviderSynced({
|
||||||
|
children,
|
||||||
|
initial,
|
||||||
|
autoSync = true,
|
||||||
|
}: AnimationPreferencesProviderProps & { autoSync?: boolean }) {
|
||||||
|
const updateMutation = api.settings.updateAnimationPreferences.useMutation();
|
||||||
|
|
||||||
|
const { data: serverPrefs } = api.settings.getAnimationPreferences.useQuery(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const [prefersReducedMotion, setPrefersReducedMotion] = useState<boolean>(
|
||||||
|
initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED,
|
||||||
|
);
|
||||||
|
const [animationSpeedMultiplier, setAnimationSpeedMultiplier] =
|
||||||
|
useState<number>(
|
||||||
|
clampSpeed(initial?.animationSpeedMultiplier ?? DEFAULT_SPEED),
|
||||||
|
);
|
||||||
|
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
|
||||||
|
const pendingSyncRef = useRef<PartialPrefs | null>(null);
|
||||||
|
const isHydratedRef = useRef(false);
|
||||||
|
const serverHydratedRef = useRef(false);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const stored = readLocalStorage();
|
||||||
|
|
||||||
|
const systemReduced = window.matchMedia?.(
|
||||||
|
"(prefers-reduced-motion: reduce)",
|
||||||
|
).matches;
|
||||||
|
|
||||||
|
const finalPrefers =
|
||||||
|
stored?.prefersReducedMotion ??
|
||||||
|
initial?.prefersReducedMotion ??
|
||||||
|
systemReduced ??
|
||||||
|
DEFAULT_PREFERS_REDUCED;
|
||||||
|
const finalSpeed = clampSpeed(
|
||||||
|
stored?.animationSpeedMultiplier ??
|
||||||
|
initial?.animationSpeedMultiplier ??
|
||||||
|
DEFAULT_SPEED,
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setPrefersReducedMotion(finalPrefers);
|
||||||
|
setAnimationSpeedMultiplier(finalSpeed);
|
||||||
|
applyPreferencesToDOM({
|
||||||
|
prefersReducedMotion: finalPrefers,
|
||||||
|
animationSpeedMultiplier: finalSpeed,
|
||||||
|
});
|
||||||
|
isHydratedRef.current = true;
|
||||||
|
}, [initial?.prefersReducedMotion, initial?.animationSpeedMultiplier]);
|
||||||
|
|
||||||
|
const performUpdate = useCallback(
|
||||||
|
(patch: PartialPrefs, opts?: { sync?: boolean }) => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
setPrefersReducedMotion((prev) => patch.prefersReducedMotion ?? prev);
|
||||||
|
setAnimationSpeedMultiplier((prev) =>
|
||||||
|
clampSpeed(patch.animationSpeedMultiplier ?? prev),
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedPatch: PartialPrefs = { ...patch };
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedPatch.prefersReducedMotion === true &&
|
||||||
|
normalizedPatch.animationSpeedMultiplier === undefined &&
|
||||||
|
animationSpeedMultiplier !== 1
|
||||||
|
) {
|
||||||
|
normalizedPatch.animationSpeedMultiplier = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextReduced =
|
||||||
|
normalizedPatch.prefersReducedMotion ?? prefersReducedMotion;
|
||||||
|
|
||||||
|
let nextSpeed = clampSpeed(
|
||||||
|
normalizedPatch.animationSpeedMultiplier ?? animationSpeedMultiplier,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextReduced && nextSpeed !== 1) {
|
||||||
|
nextSpeed = 1;
|
||||||
|
normalizedPatch.animationSpeedMultiplier ??= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPrefs = {
|
||||||
|
prefersReducedMotion: nextReduced,
|
||||||
|
animationSpeedMultiplier: nextSpeed,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyPreferencesToDOM(newPrefs);
|
||||||
|
writeLocalStorage(newPrefs);
|
||||||
|
|
||||||
|
const shouldSync = opts?.sync ?? autoSync;
|
||||||
|
|
||||||
|
if (shouldSync && serverPrefs) {
|
||||||
|
pendingSyncRef.current = {
|
||||||
|
prefersReducedMotion: patch.prefersReducedMotion,
|
||||||
|
animationSpeedMultiplier: patch.animationSpeedMultiplier,
|
||||||
|
};
|
||||||
|
updateMutation.mutate(
|
||||||
|
{
|
||||||
|
...(normalizedPatch.prefersReducedMotion !== undefined && {
|
||||||
|
prefersReducedMotion: normalizedPatch.prefersReducedMotion,
|
||||||
|
}),
|
||||||
|
...(normalizedPatch.animationSpeedMultiplier !== undefined && {
|
||||||
|
animationSpeedMultiplier: clampSpeed(
|
||||||
|
normalizedPatch.animationSpeedMultiplier,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setLastSyncedAt(Date.now());
|
||||||
|
pendingSyncRef.current = null;
|
||||||
|
setIsUpdating(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setIsUpdating(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
prefersReducedMotion,
|
||||||
|
animationSpeedMultiplier,
|
||||||
|
autoSync,
|
||||||
|
updateMutation,
|
||||||
|
serverPrefs,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isHydratedRef.current) return;
|
||||||
|
if (serverHydratedRef.current) return;
|
||||||
|
if (!serverPrefs) return;
|
||||||
|
|
||||||
|
const localIsDefault =
|
||||||
|
prefersReducedMotion === DEFAULT_PREFERS_REDUCED &&
|
||||||
|
animationSpeedMultiplier === DEFAULT_SPEED;
|
||||||
|
|
||||||
|
const differs =
|
||||||
|
serverPrefs.prefersReducedMotion !== prefersReducedMotion ||
|
||||||
|
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
|
||||||
|
|
||||||
|
if (localIsDefault || differs) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
performUpdate(
|
||||||
|
{
|
||||||
|
prefersReducedMotion: serverPrefs.prefersReducedMotion,
|
||||||
|
animationSpeedMultiplier: serverPrefs.animationSpeedMultiplier,
|
||||||
|
},
|
||||||
|
{ sync: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
serverHydratedRef.current = true;
|
||||||
|
}, [
|
||||||
|
serverPrefs,
|
||||||
|
performUpdate,
|
||||||
|
prefersReducedMotion,
|
||||||
|
animationSpeedMultiplier,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updatePreferences = useCallback<
|
||||||
|
AnimationPreferencesContextValue["updatePreferences"]
|
||||||
|
>(
|
||||||
|
(patch, opts) => {
|
||||||
|
performUpdate(patch, opts);
|
||||||
|
},
|
||||||
|
[performUpdate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSetReduced = useCallback(
|
||||||
|
(val: boolean) => {
|
||||||
|
updatePreferences({ prefersReducedMotion: val });
|
||||||
|
},
|
||||||
|
[updatePreferences],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSetSpeed = useCallback(
|
||||||
|
(val: number) => {
|
||||||
|
updatePreferences({ animationSpeedMultiplier: clampSpeed(val) });
|
||||||
|
},
|
||||||
|
[updatePreferences],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value: AnimationPreferencesContextValue = {
|
||||||
|
prefersReducedMotion,
|
||||||
|
animationSpeedMultiplier,
|
||||||
|
updatePreferences,
|
||||||
|
setPrefersReducedMotion: handleSetReduced,
|
||||||
|
setAnimationSpeedMultiplier: handleSetSpeed,
|
||||||
|
isUpdating: isUpdating || updateMutation.isPending,
|
||||||
|
lastSyncedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimationPreferencesContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AnimationPreferencesContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,58 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import {
|
||||||
* 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, {
|
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
|
|
||||||
type AnimationPreferences = {
|
type AnimationPreferences = {
|
||||||
prefersReducedMotion: boolean;
|
prefersReducedMotion: boolean;
|
||||||
@@ -61,7 +15,7 @@ type AnimationPreferences = {
|
|||||||
|
|
||||||
type PartialPrefs = Partial<AnimationPreferences>;
|
type PartialPrefs = Partial<AnimationPreferences>;
|
||||||
|
|
||||||
interface AnimationPreferencesContextValue extends AnimationPreferences {
|
export interface AnimationPreferencesContextValue extends AnimationPreferences {
|
||||||
updatePreferences: (patch: PartialPrefs, opts?: { sync?: boolean }) => void;
|
updatePreferences: (patch: PartialPrefs, opts?: { sync?: boolean }) => void;
|
||||||
setPrefersReducedMotion: (val: boolean) => void;
|
setPrefersReducedMotion: (val: boolean) => void;
|
||||||
setAnimationSpeedMultiplier: (val: number) => void;
|
setAnimationSpeedMultiplier: (val: number) => void;
|
||||||
@@ -69,33 +23,26 @@ interface AnimationPreferencesContextValue extends AnimationPreferences {
|
|||||||
lastSyncedAt: number | null;
|
lastSyncedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnimationPreferencesProviderProps {
|
export interface AnimationPreferencesProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/**
|
|
||||||
* Optional initial values (e.g. from server / tRPC prefetch).
|
|
||||||
*/
|
|
||||||
initial?: PartialPrefs;
|
initial?: PartialPrefs;
|
||||||
/**
|
|
||||||
* Disable auto-sync to server (mostly for test environments).
|
|
||||||
*/
|
|
||||||
autoSync?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "bv.animation.prefs";
|
export const STORAGE_KEY = "bv.animation.prefs";
|
||||||
const MIN_SPEED = 0.25;
|
export const MIN_SPEED = 0.25;
|
||||||
const MAX_SPEED = 4;
|
export const MAX_SPEED = 4;
|
||||||
const DEFAULT_SPEED = 1;
|
export const DEFAULT_SPEED = 1;
|
||||||
const DEFAULT_PREFERS_REDUCED = false;
|
export const DEFAULT_PREFERS_REDUCED = false;
|
||||||
|
|
||||||
const AnimationPreferencesContext =
|
export const AnimationPreferencesContext =
|
||||||
createContext<AnimationPreferencesContextValue | null>(null);
|
createContext<AnimationPreferencesContextValue | null>(null);
|
||||||
|
|
||||||
function clampSpeed(value: number): number {
|
export function clampSpeed(value: number): number {
|
||||||
if (Number.isNaN(value)) return DEFAULT_SPEED;
|
if (Number.isNaN(value)) return DEFAULT_SPEED;
|
||||||
return Math.min(MAX_SPEED, Math.max(MIN_SPEED, value));
|
return Math.min(MAX_SPEED, Math.max(MIN_SPEED, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLocalStorage(): PartialPrefs | null {
|
export function readLocalStorage(): PartialPrefs | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -122,7 +69,7 @@ function readLocalStorage(): PartialPrefs | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeLocalStorage(prefs: AnimationPreferences) {
|
export function writeLocalStorage(prefs: AnimationPreferences) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
@@ -136,18 +83,16 @@ function writeLocalStorage(prefs: AnimationPreferences) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPreferencesToDOM(prefs: AnimationPreferences) {
|
export function applyPreferencesToDOM(prefs: AnimationPreferences) {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
// Class toggle
|
|
||||||
if (prefs.prefersReducedMotion) {
|
if (prefs.prefersReducedMotion) {
|
||||||
root.classList.add("user-reduce-motion");
|
root.classList.add("user-reduce-motion");
|
||||||
} else {
|
} else {
|
||||||
root.classList.remove("user-reduce-motion");
|
root.classList.remove("user-reduce-motion");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive effective speeds
|
|
||||||
const multiplier = prefs.animationSpeedMultiplier || 1;
|
const multiplier = prefs.animationSpeedMultiplier || 1;
|
||||||
|
|
||||||
const fast = prefs.prefersReducedMotion
|
const fast = prefs.prefersReducedMotion
|
||||||
@@ -165,28 +110,11 @@ function applyPreferencesToDOM(prefs: AnimationPreferences) {
|
|||||||
root.style.setProperty("--animation-speed-slow", `${slow}s`);
|
root.style.setProperty("--animation-speed-slow", `${slow}s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Local-only animation preferences for marketing and auth pages (no tRPC). */
|
||||||
* Provider component
|
|
||||||
*/
|
|
||||||
export function AnimationPreferencesProvider({
|
export function AnimationPreferencesProvider({
|
||||||
children,
|
children,
|
||||||
initial,
|
initial,
|
||||||
autoSync = true,
|
|
||||||
}: AnimationPreferencesProviderProps) {
|
}: 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>(
|
const [prefersReducedMotion, setPrefersReducedMotion] = useState<boolean>(
|
||||||
initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED,
|
initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED,
|
||||||
);
|
);
|
||||||
@@ -194,17 +122,10 @@ export function AnimationPreferencesProvider({
|
|||||||
useState<number>(
|
useState<number>(
|
||||||
clampSpeed(initial?.animationSpeedMultiplier ?? DEFAULT_SPEED),
|
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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const stored = readLocalStorage();
|
const stored = readLocalStorage();
|
||||||
|
|
||||||
const systemReduced = window.matchMedia?.(
|
const systemReduced = window.matchMedia?.(
|
||||||
"(prefers-reduced-motion: reduce)",
|
"(prefers-reduced-motion: reduce)",
|
||||||
).matches;
|
).matches;
|
||||||
@@ -220,171 +141,47 @@ export function AnimationPreferencesProvider({
|
|||||||
DEFAULT_SPEED,
|
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);
|
setPrefersReducedMotion(finalPrefers);
|
||||||
setAnimationSpeedMultiplier(finalSpeed);
|
setAnimationSpeedMultiplier(finalSpeed);
|
||||||
applyPreferencesToDOM({
|
applyPreferencesToDOM({
|
||||||
prefersReducedMotion: finalPrefers,
|
prefersReducedMotion: finalPrefers,
|
||||||
animationSpeedMultiplier: finalSpeed,
|
animationSpeedMultiplier: finalSpeed,
|
||||||
});
|
});
|
||||||
isHydratedRef.current = true;
|
|
||||||
}, [initial?.prefersReducedMotion, initial?.animationSpeedMultiplier]);
|
}, [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<
|
const updatePreferences = useCallback<
|
||||||
AnimationPreferencesContextValue["updatePreferences"]
|
AnimationPreferencesContextValue["updatePreferences"]
|
||||||
>(
|
>((patch) => {
|
||||||
(patch, opts) => {
|
const nextReduced = patch.prefersReducedMotion ?? prefersReducedMotion;
|
||||||
performUpdate(patch, opts);
|
let nextSpeed = clampSpeed(
|
||||||
},
|
patch.animationSpeedMultiplier ?? animationSpeedMultiplier,
|
||||||
[performUpdate],
|
);
|
||||||
);
|
if (nextReduced && nextSpeed !== 1) {
|
||||||
|
nextSpeed = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Dedicated setters (they sync by default)
|
setPrefersReducedMotion(nextReduced);
|
||||||
const handleSetReduced = useCallback(
|
setAnimationSpeedMultiplier(nextSpeed);
|
||||||
(val: boolean) => {
|
applyPreferencesToDOM({
|
||||||
updatePreferences({ prefersReducedMotion: val });
|
prefersReducedMotion: nextReduced,
|
||||||
},
|
animationSpeedMultiplier: nextSpeed,
|
||||||
[updatePreferences],
|
});
|
||||||
);
|
writeLocalStorage({
|
||||||
|
prefersReducedMotion: nextReduced,
|
||||||
const handleSetSpeed = useCallback(
|
animationSpeedMultiplier: nextSpeed,
|
||||||
(val: number) => {
|
});
|
||||||
updatePreferences({ animationSpeedMultiplier: clampSpeed(val) });
|
}, [prefersReducedMotion, animationSpeedMultiplier]);
|
||||||
},
|
|
||||||
[updatePreferences],
|
|
||||||
);
|
|
||||||
|
|
||||||
const value: AnimationPreferencesContextValue = {
|
const value: AnimationPreferencesContextValue = {
|
||||||
prefersReducedMotion,
|
prefersReducedMotion,
|
||||||
animationSpeedMultiplier,
|
animationSpeedMultiplier,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
setPrefersReducedMotion: handleSetReduced,
|
setPrefersReducedMotion: (val) => updatePreferences({ prefersReducedMotion: val }),
|
||||||
setAnimationSpeedMultiplier: handleSetSpeed,
|
setAnimationSpeedMultiplier: (val) =>
|
||||||
isUpdating: isUpdating || updateMutation.isPending,
|
updatePreferences({ animationSpeedMultiplier: clampSpeed(val) }),
|
||||||
lastSyncedAt,
|
isUpdating: false,
|
||||||
|
lastSyncedAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -394,13 +191,9 @@ export function AnimationPreferencesProvider({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook consumer
|
|
||||||
*/
|
|
||||||
export function useAnimationPreferences(): AnimationPreferencesContextValue {
|
export function useAnimationPreferences(): AnimationPreferencesContextValue {
|
||||||
const ctx = useContext(AnimationPreferencesContext);
|
const ctx = useContext(AnimationPreferencesContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
// Fallback instead of throwing to prevent runtime crashes if provider is missing
|
|
||||||
console.warn("useAnimationPreferences used without provider");
|
console.warn("useAnimationPreferences used without provider");
|
||||||
return {
|
return {
|
||||||
prefersReducedMotion: false,
|
prefersReducedMotion: false,
|
||||||
@@ -421,16 +214,6 @@ export function useAnimationPreferences(): AnimationPreferencesContextValue {
|
|||||||
return ctx;
|
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 {
|
export function getInlineAnimationPrefsScript(): string {
|
||||||
return `
|
return `
|
||||||
(function(){
|
(function(){
|
||||||
@@ -445,7 +228,6 @@ export function getInlineAnimationPrefsScript(): string {
|
|||||||
if (typeof parsed.prefersReducedMotion === 'boolean') {
|
if (typeof parsed.prefersReducedMotion === 'boolean') {
|
||||||
prefersReduced = parsed.prefersReducedMotion;
|
prefersReduced = parsed.prefersReducedMotion;
|
||||||
} else {
|
} else {
|
||||||
// fallback to system preference if available
|
|
||||||
prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
}
|
}
|
||||||
if (typeof parsed.animationSpeedMultiplier === 'number') {
|
if (typeof parsed.animationSpeedMultiplier === 'number') {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AnimationPreferencesProviderSynced } from "~/components/providers/animation-preferences-provider-synced";
|
||||||
|
import { AppearanceProviderSynced } from "~/components/providers/appearance-provider-synced";
|
||||||
|
import { TRPCReactProvider } from "~/trpc/react";
|
||||||
|
|
||||||
|
/** Full app providers for authenticated workspace routes. */
|
||||||
|
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<TRPCReactProvider>
|
||||||
|
<AppearanceProviderSynced>
|
||||||
|
<AnimationPreferencesProviderSynced>
|
||||||
|
{children}
|
||||||
|
</AnimationPreferencesProviderSynced>
|
||||||
|
</AppearanceProviderSynced>
|
||||||
|
</TRPCReactProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { isHslChannels } from "~/lib/appearance";
|
||||||
|
import type { ColorMode, ColorTheme, FontPreference, InterfaceTheme, RadiusPreference, SidebarStyle } from "~/lib/branding";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import {
|
||||||
|
AppearanceContext,
|
||||||
|
applyAppearance,
|
||||||
|
defaultAppearance,
|
||||||
|
readStoredAppearance,
|
||||||
|
writeStoredAppearance,
|
||||||
|
type AppearanceContextValue,
|
||||||
|
type AppearancePatch,
|
||||||
|
type AppearancePreferences,
|
||||||
|
} from "~/components/providers/appearance-provider";
|
||||||
|
|
||||||
|
type ServerAppearance = {
|
||||||
|
interfaceTheme: InterfaceTheme;
|
||||||
|
bodyFontPreference: FontPreference;
|
||||||
|
headingFontPreference: FontPreference;
|
||||||
|
radiusPreference: RadiusPreference;
|
||||||
|
sidebarStyle: SidebarStyle;
|
||||||
|
theme: ColorMode;
|
||||||
|
colorTheme: ColorTheme;
|
||||||
|
customColor?: string;
|
||||||
|
brandName: string;
|
||||||
|
brandTagline: string;
|
||||||
|
brandLogoText: string;
|
||||||
|
brandIcon: string;
|
||||||
|
pdfTemplate: AppearancePreferences["pdfTemplate"];
|
||||||
|
pdfAccentColor: string;
|
||||||
|
pdfFooterText: string;
|
||||||
|
pdfShowLogo: boolean;
|
||||||
|
pdfShowPageNumbers: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getServerAppearancePatch(
|
||||||
|
serverAppearance: ServerAppearance,
|
||||||
|
): AppearancePatch {
|
||||||
|
return {
|
||||||
|
interfaceTheme: serverAppearance.interfaceTheme,
|
||||||
|
bodyFontPreference: serverAppearance.bodyFontPreference,
|
||||||
|
headingFontPreference: serverAppearance.headingFontPreference,
|
||||||
|
radiusPreference: serverAppearance.radiusPreference,
|
||||||
|
sidebarStyle: serverAppearance.sidebarStyle,
|
||||||
|
colorMode: serverAppearance.theme,
|
||||||
|
colorTheme: serverAppearance.colorTheme,
|
||||||
|
customColor: serverAppearance.customColor,
|
||||||
|
brandName: serverAppearance.brandName,
|
||||||
|
brandTagline: serverAppearance.brandTagline,
|
||||||
|
brandLogoText: serverAppearance.brandLogoText,
|
||||||
|
brandIcon: serverAppearance.brandIcon,
|
||||||
|
pdfTemplate: serverAppearance.pdfTemplate,
|
||||||
|
pdfAccentColor: serverAppearance.pdfAccentColor,
|
||||||
|
pdfFooterText: serverAppearance.pdfFooterText,
|
||||||
|
pdfShowLogo: serverAppearance.pdfShowLogo,
|
||||||
|
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard appearance provider with tRPC theme sync. Must render inside TRPCReactProvider. */
|
||||||
|
export function AppearanceProviderSynced({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [appearance, setAppearance] =
|
||||||
|
useState<AppearancePreferences>(defaultAppearance);
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const updateMutation = api.settings.updateTheme.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.settings.getTheme.invalidate();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
const cachedAppearance = utils.settings.getTheme.getData();
|
||||||
|
const fallback = cachedAppearance
|
||||||
|
? {
|
||||||
|
...defaultAppearance,
|
||||||
|
...getServerAppearancePatch(cachedAppearance),
|
||||||
|
}
|
||||||
|
: defaultAppearance;
|
||||||
|
|
||||||
|
setAppearance(fallback);
|
||||||
|
applyAppearance(fallback);
|
||||||
|
writeStoredAppearance(fallback);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistAppearance = useCallback(
|
||||||
|
(patch: AppearancePatch) => {
|
||||||
|
if (
|
||||||
|
patch.customColor !== undefined &&
|
||||||
|
!isHslChannels(patch.customColor)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMutation.mutate({
|
||||||
|
interfaceTheme: patch.interfaceTheme,
|
||||||
|
bodyFontPreference: patch.bodyFontPreference,
|
||||||
|
headingFontPreference: patch.headingFontPreference,
|
||||||
|
radiusPreference: patch.radiusPreference,
|
||||||
|
sidebarStyle: patch.sidebarStyle,
|
||||||
|
theme: patch.colorMode,
|
||||||
|
colorTheme: patch.colorTheme,
|
||||||
|
customColor: patch.customColor,
|
||||||
|
brandName: patch.brandName,
|
||||||
|
brandTagline: patch.brandTagline,
|
||||||
|
brandLogoText: patch.brandLogoText,
|
||||||
|
brandIcon: patch.brandIcon,
|
||||||
|
pdfTemplate: patch.pdfTemplate,
|
||||||
|
pdfAccentColor: patch.pdfAccentColor,
|
||||||
|
pdfFooterText: patch.pdfFooterText,
|
||||||
|
pdfShowLogo: patch.pdfShowLogo,
|
||||||
|
pdfShowPageNumbers: patch.pdfShowPageNumbers,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedAppearance = readStoredAppearance();
|
||||||
|
if (!storedAppearance) return;
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!serverAppearance) return;
|
||||||
|
const next = getServerAppearancePatch(serverAppearance);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setAppearance((prev) => ({ ...prev, ...next }));
|
||||||
|
}, [serverAppearance]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyAppearance(appearance);
|
||||||
|
writeStoredAppearance(appearance);
|
||||||
|
}, [appearance]);
|
||||||
|
|
||||||
|
const updateAppearance = useCallback(
|
||||||
|
(patch: AppearancePatch) => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
|
||||||
|
persistAppearance(pendingDebouncedPatchRef.current);
|
||||||
|
pendingDebouncedPatchRef.current = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
setAppearance((prev) => {
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
applyAppearance(next);
|
||||||
|
writeStoredAppearance(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
persistAppearance(patch);
|
||||||
|
},
|
||||||
|
[persistAppearance],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateAppearanceDebounced = useCallback(
|
||||||
|
(patch: AppearancePatch) => {
|
||||||
|
pendingDebouncedPatchRef.current = {
|
||||||
|
...pendingDebouncedPatchRef.current,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
|
||||||
|
setAppearance((prev) => {
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
applyAppearance(next);
|
||||||
|
writeStoredAppearance(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
persistAppearance(pendingDebouncedPatchRef.current);
|
||||||
|
pendingDebouncedPatchRef.current = {};
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
[persistAppearance],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
pendingDebouncedPatchRef.current = {};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<AppearanceContextValue>(
|
||||||
|
() => ({
|
||||||
|
...appearance,
|
||||||
|
updateAppearance,
|
||||||
|
updateAppearanceDebounced,
|
||||||
|
isUpdating: updateMutation.isPending,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
appearance,
|
||||||
|
updateAppearance,
|
||||||
|
updateAppearanceDebounced,
|
||||||
|
updateMutation.isPending,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppearanceContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AppearanceContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
@@ -35,9 +34,8 @@ import {
|
|||||||
type RadiusPreference,
|
type RadiusPreference,
|
||||||
type SidebarStyle,
|
type SidebarStyle,
|
||||||
} from "~/lib/branding";
|
} from "~/lib/branding";
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
|
|
||||||
type AppearancePreferences = {
|
export type AppearancePreferences = {
|
||||||
interfaceTheme: InterfaceTheme;
|
interfaceTheme: InterfaceTheme;
|
||||||
bodyFontPreference: FontPreference;
|
bodyFontPreference: FontPreference;
|
||||||
headingFontPreference: FontPreference;
|
headingFontPreference: FontPreference;
|
||||||
@@ -57,37 +55,17 @@ type AppearancePreferences = {
|
|||||||
pdfShowPageNumbers: boolean;
|
pdfShowPageNumbers: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppearancePatch = Partial<AppearancePreferences>;
|
export type AppearancePatch = Partial<AppearancePreferences>;
|
||||||
|
|
||||||
type ServerAppearance = {
|
export type AppearanceContextValue = AppearancePreferences & {
|
||||||
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 & {
|
|
||||||
updateAppearance: (patch: AppearancePatch) => void;
|
updateAppearance: (patch: AppearancePatch) => void;
|
||||||
updateAppearanceDebounced: (patch: AppearancePatch) => void;
|
updateAppearanceDebounced: (patch: AppearancePatch) => void;
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = "bv.appearance";
|
export const STORAGE_KEY = "bv.appearance";
|
||||||
|
|
||||||
const defaultAppearance: AppearancePreferences = {
|
export const defaultAppearance: AppearancePreferences = {
|
||||||
interfaceTheme: defaultInterfaceTheme,
|
interfaceTheme: defaultInterfaceTheme,
|
||||||
bodyFontPreference: defaultBodyFontPreference,
|
bodyFontPreference: defaultBodyFontPreference,
|
||||||
headingFontPreference: defaultHeadingFontPreference,
|
headingFontPreference: defaultHeadingFontPreference,
|
||||||
@@ -106,33 +84,10 @@ const defaultAppearance: AppearancePreferences = {
|
|||||||
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
|
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
|
export const AppearanceContext =
|
||||||
|
createContext<AppearanceContextValue | null>(null);
|
||||||
|
|
||||||
function getServerAppearancePatch(
|
export function readStoredAppearance(): Partial<AppearancePreferences> | null {
|
||||||
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 {
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -201,7 +156,7 @@ function readStoredAppearance(): Partial<AppearancePreferences> | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeStoredAppearance(prefs: AppearancePreferences) {
|
export function writeStoredAppearance(prefs: AppearancePreferences) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -209,7 +164,7 @@ function writeStoredAppearance(prefs: AppearancePreferences) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAppearance(prefs: AppearancePreferences) {
|
export function applyAppearance(prefs: AppearancePreferences) {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
const root = document.documentElement;
|
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({
|
export function AppearanceProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -237,65 +193,6 @@ export function AppearanceProvider({
|
|||||||
}) {
|
}) {
|
||||||
const [appearance, setAppearance] =
|
const [appearance, setAppearance] =
|
||||||
useState<AppearancePreferences>(defaultAppearance);
|
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(() => {
|
useEffect(() => {
|
||||||
const storedAppearance = readStoredAppearance();
|
const storedAppearance = readStoredAppearance();
|
||||||
@@ -305,92 +202,37 @@ export function AppearanceProvider({
|
|||||||
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
|
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(() => {
|
useEffect(() => {
|
||||||
applyAppearance(appearance);
|
applyAppearance(appearance);
|
||||||
writeStoredAppearance(appearance);
|
writeStoredAppearance(appearance);
|
||||||
}, [appearance]);
|
}, [appearance]);
|
||||||
|
|
||||||
const updateAppearance = useCallback(
|
const updateAppearance = useCallback((patch: AppearancePatch) => {
|
||||||
(patch: AppearancePatch) => {
|
setAppearance((prev) => {
|
||||||
if (debounceTimerRef.current) {
|
const next = { ...prev, ...patch };
|
||||||
clearTimeout(debounceTimerRef.current);
|
applyAppearance(next);
|
||||||
debounceTimerRef.current = null;
|
writeStoredAppearance(next);
|
||||||
}
|
return next;
|
||||||
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
|
});
|
||||||
persistAppearance(pendingDebouncedPatchRef.current);
|
}, []);
|
||||||
pendingDebouncedPatchRef.current = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
setAppearance((prev) => {
|
const updateAppearanceDebounced = useCallback((patch: AppearancePatch) => {
|
||||||
const next = { ...prev, ...patch };
|
setAppearance((prev) => {
|
||||||
applyAppearance(next);
|
const next = { ...prev, ...patch };
|
||||||
writeStoredAppearance(next);
|
applyAppearance(next);
|
||||||
return 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>(
|
const value = useMemo<AppearanceContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
...appearance,
|
...appearance,
|
||||||
updateAppearance,
|
updateAppearance,
|
||||||
updateAppearanceDebounced,
|
updateAppearanceDebounced,
|
||||||
isUpdating: updateMutation.isPending,
|
isUpdating: false,
|
||||||
}),
|
}),
|
||||||
[
|
[appearance, updateAppearance, updateAppearanceDebounced],
|
||||||
appearance,
|
|
||||||
updateAppearance,
|
|
||||||
updateAppearanceDebounced,
|
|
||||||
updateMutation.isPending,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
|
||||||
|
import { AppearanceProvider } from "~/components/providers/appearance-provider";
|
||||||
|
|
||||||
|
/** Client providers for public/marketing pages — no tRPC or DB-backed settings sync. */
|
||||||
|
export function MarketingProviders({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AppearanceProvider>
|
||||||
|
<AnimationPreferencesProvider>{children}</AnimationPreferencesProvider>
|
||||||
|
</AppearanceProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-4
@@ -3,10 +3,17 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||||
|
|
||||||
/**
|
function resolveAuthBaseUrl(): string | undefined {
|
||||||
* Auth client configuration
|
// 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({
|
export const authClient = createAuthClient({
|
||||||
baseURL: process.env.NEXT_PUBLIC_APP_URL,
|
baseURL: resolveAuthBaseUrl(),
|
||||||
plugins: [genericOAuthClient()],
|
plugins: [genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import { auth } from "~/lib/auth";
|
||||||
|
|
||||||
|
export function hasSessionCookie(headers: Headers): boolean {
|
||||||
|
const cookie = headers.get("cookie") ?? "";
|
||||||
|
return (
|
||||||
|
cookie.includes("better-auth.session_token=") ||
|
||||||
|
cookie.includes("__Secure-better-auth.session_token=")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOptionalServerSession(headers: Headers) {
|
||||||
|
if (!hasSessionCookie(headers)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await auth.api.getSession({ headers });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[auth] Failed to resolve session:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOptionalServerSessionFromHeaders() {
|
||||||
|
return getOptionalServerSession(await nextHeaders());
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/** Routes that do not require authentication or server session lookups. */
|
||||||
|
export const PUBLIC_ROUTES = [
|
||||||
|
"/",
|
||||||
|
"/auth/signin",
|
||||||
|
"/auth/register",
|
||||||
|
"/auth/forgot-password",
|
||||||
|
"/auth/reset-password",
|
||||||
|
"/privacy",
|
||||||
|
"/terms",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Path prefixes treated as public (e.g. shareable invoice links). */
|
||||||
|
export const PUBLIC_ROUTE_PREFIXES = ["/i/"] as const;
|
||||||
|
|
||||||
|
export function isPublicRoute(pathname: string): boolean {
|
||||||
|
if ((PUBLIC_ROUTES as readonly string[]).includes(pathname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||||
|
}
|
||||||
+2
-10
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { envBoolean } from "~/lib/env-boolean";
|
import { envBoolean } from "~/lib/env-boolean";
|
||||||
|
import { isPublicRoute } from "~/lib/public-routes";
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
@@ -11,15 +12,6 @@ export function proxy(request: NextRequest) {
|
|||||||
return NextResponse.redirect(signInUrl);
|
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
|
// Define API routes that should be handled separately
|
||||||
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"];
|
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"];
|
||||||
|
|
||||||
@@ -29,7 +21,7 @@ export function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Allow public routes for everyone
|
// Allow public routes for everyone
|
||||||
if (publicRoutes.includes(pathname)) {
|
if (isPublicRoute(pathname)) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-10
@@ -12,6 +12,7 @@ import superjson from "superjson";
|
|||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import { auth } from "~/lib/auth";
|
import { auth } from "~/lib/auth";
|
||||||
|
import { hasSessionCookie } from "~/lib/auth-server";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { getBearerToken, getUserForApiKey } from "~/server/api/api-keys";
|
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({
|
if (!hasSessionCookie(opts.headers)) {
|
||||||
headers: opts.headers,
|
return {
|
||||||
});
|
db,
|
||||||
|
session: null,
|
||||||
|
authSource: "none" as const,
|
||||||
|
apiKeyId: null,
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
try {
|
||||||
db,
|
const session = await auth.api.getSession({
|
||||||
session,
|
headers: opts.headers,
|
||||||
authSource: session?.user ? ("session" as const) : ("none" as const),
|
});
|
||||||
apiKeyId: null,
|
|
||||||
...opts,
|
return {
|
||||||
};
|
db,
|
||||||
|
session,
|
||||||
|
authSource: session?.user ? ("session" as const) : ("none" as const),
|
||||||
|
apiKeyId: null,
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[tRPC] Failed to resolve session:", error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
session: null,
|
||||||
|
authSource: "none" as const,
|
||||||
|
apiKeyId: null,
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user