Unify the dashboard experience and retire the multi-theme engine so onboarding and day-to-day invoicing feel consistent and easier to maintain.

Shared layout, tabs, and sidebar timer; user onboarding and registration polish; settings danger zone and data export; chart and tRPC perf fixes; migrations for onboarding and dropped appearance columns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 03:08:22 -04:00
co-authored by Cursor
parent 6ec26a4a0d
commit c53f2e6c4d
79 changed files with 2871 additions and 3576 deletions
+1 -15
View File
@@ -85,27 +85,13 @@ POSTGRES_DB=postgres
# White-label defaults (optional) # White-label defaults (optional)
# ============================================================================= # =============================================================================
# Baked in at Docker build. After first deploy, admins can override many of # Baked in at Docker build. After first deploy, admins can override many of
# these from Settings → Appearance in the dashboard. # Optional white-label defaults (build-time). Users choose light/dark in Settings.
NEXT_PUBLIC_BRAND_NAME=beenvoice NEXT_PUBLIC_BRAND_NAME=beenvoice
NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses
NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
NEXT_PUBLIC_BRAND_ICON=$ NEXT_PUBLIC_BRAND_ICON=$
# Interface theme: beenvoice | frutiger | frutiger-aero | shadcn | minimal | editorial
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME=beenvoice
# Font prefs: brand | frutiger | platform | inter | serif
NEXT_PUBLIC_DEFAULT_FONT=brand
NEXT_PUBLIC_DEFAULT_BODY_FONT=brand
NEXT_PUBLIC_DEFAULT_HEADING_FONT=brand
# Corner radius: none | sm | md | lg | xl
NEXT_PUBLIC_DEFAULT_RADIUS=xl
# Sidebar chrome: floating | docked
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE=floating
# ============================================================================= # =============================================================================
# Email — Resend (optional) # Email — Resend (optional)
# ============================================================================= # =============================================================================
+2
View File
@@ -73,6 +73,8 @@ Start Postgres (dev compose exposes port 5432):
docker compose -f docker-compose.dev.yml up -d docker compose -f docker-compose.dev.yml up -d
``` ```
After a fresh volume (`docker compose down -v`), Postgres starts empty — you must apply schema before registering or signing in.
Apply schema (pick one): Apply schema (pick one):
```bash ```bash
@@ -0,0 +1,39 @@
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "colorTheme";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "customColor";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "interfaceTheme";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "fontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "bodyFontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "headingFontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "radiusPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "sidebarStyle";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandName";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandTagline";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandLogoText";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandIcon";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "colorTheme";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "customColor";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "theme";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "interfaceTheme";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "bodyFontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "headingFontPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "radiusPreference";
--> statement-breakpoint
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "sidebarStyle";
+11
View File
@@ -0,0 +1,11 @@
ALTER TABLE "beenvoice_user" ADD COLUMN IF NOT EXISTS "onboardingCompletedAt" timestamp;
-- Users who already have a business are treated as onboarded
UPDATE "beenvoice_user" u
SET "onboardingCompletedAt" = COALESCE(u."onboardingCompletedAt", NOW())
WHERE u."onboardingCompletedAt" IS NULL
AND EXISTS (
SELECT 1
FROM "beenvoice_business" b
WHERE b."createdById" = u."id"
);
+14
View File
@@ -120,6 +120,20 @@
"when": 1781500000000, "when": 1781500000000,
"tag": "0016_fix_send_reminder_at_column", "tag": "0016_fix_send_reminder_at_column",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1781600000000,
"tag": "0017_drop_theme_engine_columns",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1781700000000,
"tag": "0018_user_onboarding",
"breakpoints": true
} }
] ]
} }
+6 -2
View File
@@ -4,6 +4,8 @@ import { db } from "~/server/db";
import { users } from "~/server/db/schema"; import { users } from "~/server/db/schema";
import { Resend } from "resend"; import { Resend } from "resend";
import { env } from "~/env"; import { env } from "~/env";
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url";
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates"; import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
import crypto from "crypto"; import crypto from "crypto";
@@ -72,7 +74,7 @@ export async function POST(request: NextRequest) {
// Send password reset email using Resend // Send password reset email using Resend
try { try {
const resend = new Resend(env.RESEND_API_KEY); const resend = new Resend(env.RESEND_API_KEY);
const resetUrl = `${process.env.BETTER_AUTH_URL ?? "http://localhost:3000"}/auth/reset-password?token=${resetToken}`; const resetUrl = `${getAppUrl()}/auth/reset-password?token=${resetToken}`;
const emailTemplate = generatePasswordResetEmailTemplate({ const emailTemplate = generatePasswordResetEmailTemplate({
userEmail: email, userEmail: email,
@@ -82,8 +84,10 @@ export async function POST(request: NextRequest) {
expiryHours: 24, expiryHours: 24,
}); });
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
await resend.emails.send({ await resend.emails.send({
from: "beenvoice <noreply@beenvoice.com>", from: `beenvoice <noreply@${fromDomain}>`,
to: email, to: email,
subject: emailTemplate.subject, subject: emailTemplate.subject,
html: emailTemplate.html, html: emailTemplate.html,
+7
View File
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { auth } from "~/lib/auth"; import { auth } from "~/lib/auth";
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
import { env } from "~/env"; import { env } from "~/env";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema"; import { accounts, users } from "~/server/db/schema";
@@ -161,6 +162,12 @@ export async function POST(request: NextRequest) {
); );
} catch (error) { } catch (error) {
console.error("Registration error:", error); console.error("Registration error:", error);
const databaseSetupError = getDatabaseSetupErrorMessage(error);
if (databaseSetupError) {
return NextResponse.json({ error: databaseSetupError }, { status: 503 });
}
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
{ status: 500 }, { status: 500 },
+2 -1
View File
@@ -3,6 +3,7 @@ import { z, type ZodType } from "zod";
import { createCaller } from "~/server/api/root"; import { createCaller } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc"; import { createTRPCContext } from "~/server/api/trpc";
import { getAppUrl } from "~/lib/app-url";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -856,7 +857,7 @@ const tools = {
schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }), schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }),
handler: async (input, caller) => { handler: async (input, caller) => {
const result = await caller.invoices.generatePublicToken(input); const result = await caller.invoices.generatePublicToken(input);
const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; const base = getAppUrl();
return { return {
...result, ...result,
webUrl: `${base}/i/${result.token}`, webUrl: `${base}/i/${result.token}`,
+2 -210
View File
@@ -1,213 +1,5 @@
"use client"; import { RegisterForm } from "./register-form";
import { useState, Suspense } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalAgreementNotice } from "~/components/legal/legal-links";
import { Mail, Lock, ArrowRight, User } from "lucide-react";
function formatAuthError(message: string | undefined, fallback: string): string {
if (!message || message === "Required") {
return fallback;
}
return message;
}
function RegisterForm() {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function handleRegister(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const trimmedFirstName = String(formData.get("firstName") ?? "").trim();
const trimmedLastName = String(formData.get("lastName") ?? "").trim();
const trimmedEmail = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!trimmedFirstName || !trimmedLastName || !trimmedEmail) {
toast.error("Please enter your first name, last name, and email.");
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters.");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: trimmedFirstName,
lastName: trimmedLastName,
email: trimmedEmail,
password,
}),
});
let data: { error?: string; signInRequired?: boolean } = {};
try {
data = (await res.json()) as typeof data;
} catch {
toast.error("Registration failed. Please try again.");
return;
}
if (!res.ok) {
toast.error(
formatAuthError(data.error, "Registration failed. Please check the form."),
);
return;
}
if (data.signInRequired) {
toast.success("Account created! Please sign in.");
router.push("/auth/signin");
return;
}
toast.success("Account created successfully!");
router.push("/dashboard");
router.refresh();
} catch {
toast.error("Registration failed. Please try again.");
} finally {
setLoading(false);
}
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
<div className="pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div>
<Card className="mx-auto w-full max-w-md border-border/50 bg-background/80 backdrop-blur-xl">
<CardContent className="p-8">
<div className="space-y-6">
<div className="space-y-2">
<Logo size="lg" />
<div>
<h1 className="font-heading text-2xl font-bold">Create your account</h1>
<p className="text-muted-foreground text-sm">Get started today</p>
</div>
</div>
<form onSubmit={handleRegister} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<div className="relative">
<User 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="firstName"
name="firstName"
type="text"
required
autoFocus
autoComplete="given-name"
className="h-10 pl-10"
placeholder="John"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<div className="relative">
<User 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="lastName"
name="lastName"
type="text"
required
autoComplete="family-name"
className="h-10 pl-10"
placeholder="Doe"
/>
</div>
</div>
</div>
<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"
name="email"
type="email"
required
autoComplete="email"
className="h-10 pl-10"
placeholder="you@example.com"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<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"
name="password"
type="password"
required
minLength={8}
autoComplete="new-password"
className="h-10 pl-10"
placeholder="••••••••"
/>
</div>
<p className="text-muted-foreground text-xs">At least 8 characters</p>
</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>Creating account</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Create Account</span>
<ArrowRight className="h-4 w-4" />
</div>
)}
</Button>
</form>
<p className="text-muted-foreground text-center text-sm">
Already have an account?{" "}
<a href="/auth/signin" className="text-foreground font-medium hover:underline">
Sign in
</a>
</p>
<LegalAgreementNotice action="creating an account" />
</div>
</CardContent>
</Card>
</div>
);
}
export default function RegisterPage() { export default function RegisterPage() {
return ( return <RegisterForm />;
<Suspense fallback={<div>Loading...</div>}>
<RegisterForm />
</Suspense>
);
} }
+201
View File
@@ -0,0 +1,201 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, Lock, Mail, User } from "lucide-react";
import {
AuthCard,
AuthCardHeader,
AuthPageShell,
} from "~/components/auth/auth-page-shell";
import { LegalAgreementNotice } from "~/components/legal/legal-links";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { toast } from "sonner";
function formatAuthError(message: string | undefined, fallback: string): string {
if (!message || message === "Required") {
return fallback;
}
return message;
}
export function RegisterForm() {
const router = useRouter();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
const trimmedFirstName = firstName.trim();
const trimmedLastName = lastName.trim();
const trimmedEmail = email.trim();
if (!trimmedFirstName || !trimmedLastName || !trimmedEmail) {
toast.error("Please enter your first name, last name, and email.");
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters.");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: trimmedFirstName,
lastName: trimmedLastName,
email: trimmedEmail,
password,
}),
});
let data: { error?: string; signInRequired?: boolean } = {};
try {
data = (await res.json()) as typeof data;
} catch {
toast.error("Registration failed. Please try again.");
return;
}
if (!res.ok) {
toast.error(
formatAuthError(data.error, "Registration failed. Please check the form."),
);
return;
}
if (data.signInRequired) {
toast.success("Account created! Please sign in.");
router.push("/auth/signin");
return;
}
toast.success("Account created successfully!");
router.push("/dashboard");
router.refresh();
} catch {
toast.error("Registration failed. Please try again.");
} finally {
setLoading(false);
}
}
return (
<AuthPageShell>
<AuthCard>
<AuthCardHeader
title="Create your account"
description="Get started with your workspace"
/>
<form onSubmit={handleRegister} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="firstName">First name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="firstName"
name="firstName"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
autoFocus
autoComplete="given-name"
className="h-11 pl-10"
placeholder="John"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last name</Label>
<div className="relative">
<User className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="lastName"
name="lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
autoComplete="family-name"
className="h-11 pl-10"
placeholder="Doe"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="h-11 pl-10"
placeholder="you@example.com"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<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"
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="h-11 pl-10"
placeholder="••••••••"
/>
</div>
<p className="text-muted-foreground text-xs">At least 8 characters</p>
</div>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{loading ? "Creating account…" : "Create account"}
{!loading && <ArrowRight className="ml-2 h-4 w-4" />}
</Button>
</form>
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{" "}
<Link
href="/auth/signin"
className="text-foreground font-medium hover:underline"
>
Sign in
</Link>
</p>
<LegalAgreementNotice action="creating an account" className="mt-5" />
</AuthCard>
</AuthPageShell>
);
}
@@ -8,12 +8,32 @@ import { Button } from "~/components/ui/button";
import { Square, Clock } from "lucide-react"; import { Square, Clock } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock"; import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { cn } from "~/lib/utils";
export function ActiveTimerWidget() { interface ActiveTimerWidgetProps {
collapsed?: boolean;
compact?: boolean;
}
export function ActiveTimerWidget({
collapsed = false,
compact = false,
}: ActiveTimerWidgetProps) {
const utils = api.useUtils(); const utils = api.useUtils();
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(undefined, { const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(
refetchInterval: 30_000, undefined,
}); {
staleTime: 60_000,
refetchOnWindowFocus: false,
refetchInterval: 60_000,
},
);
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -56,9 +76,6 @@ export function ActiveTimerWidget() {
} }
void utils.timeEntries.getRunning.invalidate(); void utils.timeEntries.getRunning.invalidate();
void utils.timeEntries.getAll.invalidate();
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
}, },
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
}); });
@@ -69,64 +86,153 @@ export function ActiveTimerWidget() {
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}` ? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: null; : null;
const description =
running.description || (
<span className="text-muted-foreground italic">No description</span>
);
const renderStopButton = (className?: string) => (
<Button
variant="destructive"
size="sm"
onClick={() => clockOut.mutate({})}
disabled={clockOut.isPending}
className={cn(compact && "h-8 px-2", className)}
>
<Square className={cn("h-3.5 w-3.5", !compact && "mr-1.5")} />
{!compact && (clockOut.isPending ? "Stopping…" : "Stop")}
</Button>
);
if (compact) {
return (
<div className="ml-auto flex flex-col items-center gap-1">
<Link
href="/dashboard/time-clock"
className="border-primary/30 bg-primary/5 flex items-center gap-2 rounded-md border px-2.5 py-1.5"
>
<span className="relative flex h-2 w-2 flex-shrink-0">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
</span>
<span className="text-primary font-mono text-sm font-bold tabular-nums">
{formatElapsedSeconds(elapsed)}
</span>
</Link>
{renderStopButton()}
</div>
);
}
if (collapsed) {
return (
<div className="flex justify-center">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/dashboard/time-clock"
className="border-primary/30 bg-primary/5 relative flex h-10 w-10 items-center justify-center rounded-md border transition-colors hover:bg-primary/10"
>
<Clock className="text-primary h-5 w-5" />
<span className="absolute top-1 right-1 flex h-2 w-2">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-56 space-y-2 p-3">
<p className="text-sm font-medium">
{description}
{running.client && (
<span className="text-muted-foreground font-normal">
{" "}
· {running.client.name}
</span>
)}
</p>
<p className="text-primary font-mono text-lg font-bold tabular-nums">
{formatElapsedSeconds(elapsed)}
</p>
{invoiceLabel ? (
<p className="text-muted-foreground text-xs">
Billing to{" "}
<Link
href={`/dashboard/invoices/${running.invoice!.id}`}
className="text-primary hover:underline"
>
{invoiceLabel}
</Link>
</p>
) : (
<p className="text-muted-foreground text-xs">No invoice selected</p>
)}
<div className="flex gap-2 pt-1">
<Button variant="outline" size="sm" asChild className="h-8 flex-1">
<Link href="/dashboard/time-clock">Open</Link>
</Button>
{renderStopButton()}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
return ( return (
<Card className="border-primary/30 bg-primary/5"> <Card className="border-primary/30 bg-primary/5">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center"> <CardContent className="flex flex-col gap-3 p-3">
<span className="relative flex h-3 w-3 flex-shrink-0"> <div className="flex items-start gap-2">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" /> <span className="relative mt-1 flex h-2.5 w-2.5 flex-shrink-0">
<span className="bg-primary relative inline-flex h-3 w-3 rounded-full" /> <span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
</span> <span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium"> <p className="text-sm leading-snug font-medium">
{running.description || ( {description}
<span className="text-muted-foreground italic">No description</span> {running.client && (
)} <span className="text-muted-foreground font-normal">
{running.client && ( {" "}
<span className="text-muted-foreground font-normal"> · {running.client.name}</span> · {running.client.name}
)} </span>
</p> )}
<p className="text-muted-foreground text-xs"> </p>
{invoiceLabel ? ( <p className="text-muted-foreground mt-1 text-xs leading-snug">
<> {invoiceLabel ? (
Billing to{" "} <>
<Link Billing to{" "}
href={`/dashboard/invoices/${running.invoice!.id}`} <Link
className="text-primary hover:underline" href={`/dashboard/invoices/${running.invoice!.id}`}
> className="text-primary hover:underline"
{invoiceLabel} >
</Link> {invoiceLabel}
</> </Link>
) : ( </>
<>No invoice selected open time clock to assign</> ) : (
)} <>No invoice selected open time clock to assign</>
{" · "} )}
<Link href="/dashboard/time-clock" className="text-primary hover:underline"> {" · "}
Time clock <Link href="/dashboard/time-clock" className="text-primary hover:underline">
</Link> Time clock
</p> </Link>
</p>
</div>
</div> </div>
<span className="text-primary font-mono text-2xl font-bold tabular-nums"> <div className="flex flex-col items-center gap-2">
{formatElapsedSeconds(elapsed)} <span className="text-primary text-center font-mono text-xl font-bold tabular-nums">
</span> {formatElapsedSeconds(elapsed)}
</span>
<div className="flex gap-2"> <div className="flex w-full flex-col gap-1.5">
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild className="h-8 w-full">
<Link href="/dashboard/time-clock"> <Link href="/dashboard/time-clock">
<Clock className="mr-1.5 h-3.5 w-3.5" /> <Clock className="mr-1 h-3.5 w-3.5" />
Open Open
</Link> </Link>
</Button> </Button>
<Button {renderStopButton("w-full")}
variant="destructive" </div>
size="sm"
onClick={() => clockOut.mutate({})}
disabled={clockOut.isPending}
>
<Square className="mr-1.5 h-3.5 w-3.5" />
{clockOut.isPending ? "Stopping…" : "Stop"}
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -8,7 +8,14 @@ import {
Clock, Clock,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { Card, CardContent } from "~/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { cn } from "~/lib/utils";
type IconName = "DollarSign" | "Clock" | "Users" | "TrendingDown"; type IconName = "DollarSign" | "Clock" | "Users" | "TrendingDown";
@@ -51,41 +58,36 @@ export function AnimatedStatsCard({
const isPositive = trend === "up"; const isPositive = trend === "up";
const isNeutral = trend === "neutral"; const isNeutral = trend === "neutral";
// For now, always use the formatted value prop to ensure correct display
// Animation can be added back once the basic display is working correctly
const displayValue = value;
// Suppress unused parameter warnings for now
void delay; void delay;
void isCurrency; void isCurrency;
void numericValue; void numericValue;
return ( return (
<Card> <Card>
<CardContent className="p-6"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="flex items-center justify-between space-y-0 pb-2"> <CardTitle className="text-muted-foreground flex items-center gap-2 text-sm font-medium">
<div className="flex items-center space-x-2"> <Icon className="h-4 w-4" />
<Icon className="text-muted-foreground h-5 w-5" /> {title}
<p className="text-muted-foreground text-sm font-medium">{title}</p> </CardTitle>
</div> <div
<div className={cn(
className="flex items-center space-x-1 text-xs" "flex items-center gap-1 text-xs font-medium",
style={{ isNeutral
color: isNeutral ? "text-muted-foreground"
? "hsl(var(--muted-foreground))" : isPositive
: isPositive ? "text-emerald-600 dark:text-emerald-400"
? "oklch(var(--chart-2))" : "text-amber-600 dark:text-amber-400",
: "oklch(var(--chart-3))", )}
}} >
> <TrendIcon className="h-3 w-3" />
<TrendIcon className="h-3 w-3" /> <span className="font-mono tabular-nums">{change}</span>
<span>{change}</span>
</div>
</div>
<div className="space-y-1">
<p className="animate-count-up text-2xl font-bold">{displayValue}</p>
<p className="text-muted-foreground text-xs">{description}</p>
</div> </div>
</CardHeader>
<CardContent className="pt-0">
<p className="font-mono text-2xl font-semibold tracking-tight tabular-nums">
{value}
</p>
<CardDescription className="mt-1">{description}</CardDescription>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -1,19 +1,18 @@
"use client"; "use client";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; import { Cell, Pie, PieChart, Tooltip } from "recharts";
import { ResponsiveChart } from "~/components/charts/responsive-chart";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
interface Invoice { export interface StatusChartDatum {
id: string;
totalAmount: number;
status: string; status: string;
dueDate: Date | string; name: string;
count: number;
value: number;
} }
interface InvoiceStatusChartProps { interface InvoiceStatusChartProps {
invoices: Invoice[]; data: StatusChartDatum[];
} }
const STATUS_COLORS = { const STATUS_COLORS = {
@@ -47,52 +46,26 @@ function StatusTooltip({
return ( return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg"> <div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{data.name}</p> <p className="font-medium">{data.name}</p>
<p className="text-sm"> <p className="font-mono text-sm tabular-nums">
{data.count} invoice{data.count !== 1 ? "s" : ""} {data.count} invoice{data.count !== 1 ? "s" : ""}
</p> </p>
<p className="text-sm">{formatChartCurrency(data.value)}</p> <p className="font-mono text-sm tabular-nums">
{formatChartCurrency(data.value)}
</p>
</div> </div>
); );
} }
return null; return null;
} }
export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { export function InvoiceStatusChart({ data }: InvoiceStatusChartProps) {
// Process invoice data to create status breakdown
const statusData = invoices.reduce(
(acc, invoice) => {
const effectiveStatus = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
acc[effectiveStatus] ??= {
status: effectiveStatus,
count: 0,
value: 0,
};
acc[effectiveStatus].count += 1;
acc[effectiveStatus].value += invoice.totalAmount;
return acc;
},
{} as Record<string, { status: string; count: number; value: number }>,
);
const chartData = Object.values(statusData).map((item) => ({
...item,
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
}));
// Animation / motion preferences
const { prefersReducedMotion, animationSpeedMultiplier } = const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences(); useAnimationPreferences();
const pieAnimationDuration = Math.round( const pieAnimationDuration = Math.round(
600 / (animationSpeedMultiplier || 1), 600 / (animationSpeedMultiplier || 1),
); );
if (chartData.length === 0) { if (data.length === 0) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<div className="text-center"> <div className="text-center">
@@ -109,11 +82,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="h-48 w-full"> <ResponsiveChart height={192} className="h-48">
<ResponsiveContainer width="100%" height="100%"> <PieChart>
<PieChart>
<Pie <Pie
data={chartData} data={data}
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={40} innerRadius={40}
@@ -124,7 +96,7 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
animationDuration={pieAnimationDuration} animationDuration={pieAnimationDuration}
animationEasing="ease-out" animationEasing="ease-out"
> >
{chartData.map((entry, index) => ( {data.map((entry, index) => (
<Cell <Cell
key={`cell-${index}`} key={`cell-${index}`}
fill={ fill={
@@ -135,12 +107,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
</Pie> </Pie>
<Tooltip content={<StatusTooltip />} /> <Tooltip content={<StatusTooltip />} />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveChart>
</div>
{/* Legend */}
<div className="space-y-2"> <div className="space-y-2">
{chartData.map((item) => ( {data.map((item) => (
<div key={item.status} className="flex items-center justify-between"> <div key={item.status} className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
@@ -153,8 +123,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
<span className="text-sm font-medium">{item.name}</span> <span className="text-sm font-medium">{item.name}</span>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-sm font-medium">{item.count}</p> <p className="font-mono text-sm font-medium tabular-nums">
<p className="text-muted-foreground text-xs"> {item.count}
</p>
<p className="text-muted-foreground font-mono text-xs tabular-nums">
{formatChartCurrency(item.value)} {formatChartCurrency(item.value)}
</p> </p>
</div> </div>
@@ -3,25 +3,25 @@
import { import {
Bar, Bar,
BarChart, BarChart,
ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { ResponsiveChart } from "~/components/charts/responsive-chart";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface Invoice { export interface MonthlyMetricsChartDatum {
id: string; month: string;
totalAmount: number; monthLabel: string;
issueDate: Date | string; totalInvoices: number;
status: string; paidInvoices: number;
dueDate: Date | string; pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
} }
interface MonthlyMetricsChartProps { interface MonthlyMetricsChartProps {
invoices: Invoice[]; data: MonthlyMetricsChartDatum[];
} }
function MonthlyMetricsTooltip({ function MonthlyMetricsTooltip({
@@ -31,28 +31,30 @@ function MonthlyMetricsTooltip({
}: { }: {
active?: boolean; active?: boolean;
payload?: Array<{ payload?: Array<{
payload: { payload: MonthlyMetricsChartDatum;
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
totalInvoices: number;
};
}>; }>;
label?: string; label?: string;
}) { }) {
if (active && payload?.length) { if (active && payload?.length) {
const data = payload[0]!.payload; const chartDatum = payload[0]!.payload;
return ( return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg"> <div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p> <p className="font-medium">{label}</p>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p> <p className="text-primary font-medium font-mono tabular-nums">
<p className="text-primary/80">Pending: {data.pendingInvoices}</p> Paid: {chartDatum.paidInvoices}
<p className="text-destructive">Overdue: {data.overdueInvoices}</p> </p>
<p className="text-muted-foreground">Draft: {data.draftInvoices}</p> <p className="text-primary/80 font-mono tabular-nums">
<p className="text-foreground border-t pt-1 font-medium"> Pending: {chartDatum.pendingInvoices}
Total: {data.totalInvoices} </p>
<p className="text-destructive font-mono tabular-nums">
Overdue: {chartDatum.overdueInvoices}
</p>
<p className="text-muted-foreground font-mono tabular-nums">
Draft: {chartDatum.draftInvoices}
</p>
<p className="text-foreground border-t pt-1 font-medium font-mono tabular-nums">
Total: {chartDatum.totalInvoices}
</p> </p>
</div> </div>
</div> </div>
@@ -61,78 +63,14 @@ function MonthlyMetricsTooltip({
return null; return null;
} }
export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) { export function MonthlyMetricsChart({ data }: MonthlyMetricsChartProps) {
// Process invoice data to create monthly metrics
const monthlyData = invoices.reduce(
(acc, invoice) => {
const date = new Date(invoice.issueDate);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
const effectiveStatus = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
acc[monthKey] ??= {
month: monthKey,
totalInvoices: 0,
paidInvoices: 0,
pendingInvoices: 0,
overdueInvoices: 0,
draftInvoices: 0,
};
acc[monthKey].totalInvoices += 1;
switch (effectiveStatus) {
case "paid":
acc[monthKey].paidInvoices += 1;
break;
case "sent":
acc[monthKey].pendingInvoices += 1;
break;
case "overdue":
acc[monthKey].overdueInvoices += 1;
break;
case "draft":
acc[monthKey].draftInvoices += 1;
break;
}
return acc;
},
{} as Record<
string,
{
month: string;
totalInvoices: number;
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
}
>,
);
// Convert to array and sort by month
const chartData = Object.values(monthlyData)
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-6) // Show last 6 months
.map((item) => ({
...item,
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}));
// Animation / motion preferences
const { prefersReducedMotion, animationSpeedMultiplier } = const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences(); useAnimationPreferences();
const barAnimationDuration = Math.round( const barAnimationDuration = Math.round(
500 / (animationSpeedMultiplier || 1), 500 / (animationSpeedMultiplier || 1),
); );
if (chartData.length === 0) { if (data.length === 0) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<div className="text-center"> <div className="text-center">
@@ -149,9 +87,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="h-48 w-full"> <ResponsiveChart height={192} className="h-48">
<ResponsiveContainer width="100%" height="100%"> <BarChart data={data}>
<BarChart data={chartData}>
<XAxis <XAxis
dataKey="monthLabel" dataKey="monthLabel"
axisLine={false} axisLine={false}
@@ -161,7 +98,11 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
<YAxis <YAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }} tick={{
fontSize: 12,
fill: "var(--muted-foreground)",
fontFamily: "var(--font-mono)",
}}
/> />
<Tooltip content={<MonthlyMetricsTooltip />} /> <Tooltip content={<MonthlyMetricsTooltip />} />
<Bar <Bar
@@ -202,10 +143,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
animationEasing="ease-out" animationEasing="ease-out"
/> />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveChart>
</div>
{/* Legend */}
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2"> <div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
@@ -3,11 +3,11 @@
import { import {
Area, Area,
AreaChart, AreaChart,
ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { ResponsiveChart } from "~/components/charts/responsive-chart";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface RevenueChartProps { interface RevenueChartProps {
@@ -41,7 +41,10 @@ const CustomTooltip = ({
return ( return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg"> <div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p> <p className="font-medium">{label}</p>
<p style={{ color: "hsl(0, 0%, 60%)" }}> <p
className="font-mono tabular-nums"
style={{ color: "hsl(0, 0%, 60%)" }}
>
Revenue: {formatCurrency(data.revenue)} Revenue: {formatCurrency(data.revenue)}
</p> </p>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
@@ -84,9 +87,8 @@ export function RevenueChart({ data }: RevenueChartProps) {
} }
return ( return (
<div className="h-48 w-full md:h-64"> <ResponsiveChart height={256} className="h-48 md:h-64">
<ResponsiveContainer width="100%" height="100%"> <AreaChart data={chartData}>
<AreaChart data={chartData}>
<defs> <defs>
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop <stop
@@ -110,7 +112,11 @@ export function RevenueChart({ data }: RevenueChartProps) {
<YAxis <YAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }} tick={{
fontSize: 12,
fill: "hsl(var(--muted-foreground))",
fontFamily: "var(--font-mono)",
}}
tickFormatter={formatCurrency} tickFormatter={formatCurrency}
/> />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip />} />
@@ -127,7 +133,6 @@ export function RevenueChart({ data }: RevenueChartProps) {
animationEasing="ease-out" animationEasing="ease-out"
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveChart>
</div>
); );
} }
+23 -5
View File
@@ -1,16 +1,34 @@
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { Suspense } from "react"; import { Suspense } from "react";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DataTableSkeleton } from "~/components/data/data-table";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
import { HydrateClient } from "~/trpc/server"; import { HydrateClient } from "~/trpc/server";
import { AdministrationContent } from "./_components/administration-content"; import { AdministrationContent } from "./_components/administration-content";
export default async function AdministrationPage() { export default async function AdministrationPage() {
const session = await getOptionalServerSessionFromHeaders();
if (session?.user) {
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
columns: { role: true },
});
if (user?.role !== "admin") {
redirect("/dashboard");
}
}
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Administration" title="Administration"
description="Manage account access and platform administration" description="Manage account access and platform administration"
variant="gradient"
/> />
<HydrateClient> <HydrateClient>
@@ -18,6 +36,6 @@ export default async function AdministrationPage() {
<AdministrationContent /> <AdministrationContent />
</Suspense> </Suspense>
</HydrateClient> </HydrateClient>
</div> </DashboardPage>
); );
} }
+13 -8
View File
@@ -3,7 +3,13 @@ import { api } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import {
DashboardPage,
dashboardGapClass,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import Link from "next/link"; import Link from "next/link";
import { import {
@@ -43,11 +49,10 @@ export default async function BusinessDetailPage({
}; };
return ( return (
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<PageHeader <DashboardPageHeader
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`} title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
description="View business details and information" description="View business details and information"
variant="gradient"
> >
<Button asChild variant="outline" className="shadow-sm"> <Button asChild variant="outline" className="shadow-sm">
<Link href="/dashboard/entities?tab=businesses"> <Link href="/dashboard/entities?tab=businesses">
@@ -61,9 +66,9 @@ export default async function BusinessDetailPage({
<span>Edit Business</span> <span>Edit Business</span>
</Link> </Link>
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
{/* Business Information Card */} {/* Business Information Card */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
@@ -265,7 +270,7 @@ export default async function BusinessDetailPage({
</div> </div>
{/* Settings & Actions Card */} {/* Settings & Actions Card */}
<div className="space-y-6"> <div className={cn("flex flex-col", dashboardGapClass)}>
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -323,6 +328,6 @@ export default async function BusinessDetailPage({
</Card> </Card>
</div> </div>
</div> </div>
</div> </DashboardPage>
); );
} }
+13 -8
View File
@@ -3,7 +3,13 @@ import { api } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import {
DashboardPage,
dashboardGapClass,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import Link from "next/link"; import Link from "next/link";
import { import {
Edit, Edit,
@@ -57,11 +63,10 @@ export default async function ClientDetailPage({
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0; client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
return ( return (
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<PageHeader <DashboardPageHeader
title={client.name} title={client.name}
description="View client details and information" description="View client details and information"
variant="gradient"
> >
<Button asChild variant="outline" className="shadow-sm"> <Button asChild variant="outline" className="shadow-sm">
<Link href="/dashboard/entities?tab=clients"> <Link href="/dashboard/entities?tab=clients">
@@ -75,9 +80,9 @@ export default async function ClientDetailPage({
<span>Edit Client</span> <span>Edit Client</span>
</Link> </Link>
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
{/* Client Information Card */} {/* Client Information Card */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
@@ -173,7 +178,7 @@ export default async function ClientDetailPage({
</div> </div>
{/* Stats Card */} {/* Stats Card */}
<div className="space-y-6"> <div className={cn("flex flex-col", dashboardGapClass)}>
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -275,6 +280,6 @@ export default async function ClientDetailPage({
)} )}
</div> </div>
</div> </div>
</div> </DashboardPage>
); );
} }
@@ -3,15 +3,32 @@
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { ClientsDataTable } from "../../clients/_components/clients-data-table";
import { ClientsTable } from "../../clients/_components/clients-table"; import { BusinessesDataTable } from "../../businesses/_components/businesses-data-table";
import { BusinessesTable } from "../../businesses/_components/businesses-table"; import type { RouterOutputs } from "~/trpc/react";
type EntityTab = "clients" | "businesses"; type EntityTab = "clients" | "businesses";
export function EntitiesView({ initialTab }: { initialTab: EntityTab }) { type Client = RouterOutputs["clients"]["getAll"][number];
type Business = RouterOutputs["businesses"]["getAll"][number];
export function EntitiesView({
initialTab,
clients,
businesses,
}: {
initialTab: EntityTab;
clients: Client[];
businesses: Business[];
}) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const tab: EntityTab = const tab: EntityTab =
@@ -27,11 +44,10 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
const addLabel = tab === "clients" ? "Add client" : "Add business"; const addLabel = tab === "clients" ? "Add client" : "Add business";
return ( return (
<div className="space-y-6"> <>
<PageHeader <DashboardPageHeader
title="Entities" title="Entities"
description="Clients you bill and businesses you send from" description="Clients you bill and businesses you send from"
variant="gradient"
> >
<Button asChild variant="default" className="hover-lift shadow-md"> <Button asChild variant="default" className="hover-lift shadow-md">
<Link href={addHref}> <Link href={addHref}>
@@ -39,22 +55,24 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
<span>{addLabel}</span> <span>{addLabel}</span>
</Link> </Link>
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<Tabs value={tab} onValueChange={handleTabChange}> <PageTabs value={tab} onValueChange={handleTabChange}>
<TabsList className="grid w-full max-w-md grid-cols-2"> <PageTabsList>
<TabsTrigger value="clients">Clients</TabsTrigger> <PageTabsTrigger value="clients">Clients</PageTabsTrigger>
<TabsTrigger value="businesses">Businesses</TabsTrigger> <PageTabsTrigger value="businesses">Businesses</PageTabsTrigger>
</TabsList> </PageTabsList>
<TabsContent value="clients" className="mt-6"> <PageTabsContent value="clients">
<ClientsTable /> {tab === "clients" ? <ClientsDataTable clients={clients} /> : null}
</TabsContent> </PageTabsContent>
<TabsContent value="businesses" className="mt-6"> <PageTabsContent value="businesses">
<BusinessesTable /> {tab === "businesses" ? (
</TabsContent> <BusinessesDataTable businesses={businesses} />
</Tabs> ) : null}
</div> </PageTabsContent>
</PageTabs>
</>
); );
} }
+13 -12
View File
@@ -1,6 +1,5 @@
import { Suspense } from "react"; import { api } from "~/trpc/server";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DashboardPage } from "~/components/layout/dashboard-page";
import { api, HydrateClient } from "~/trpc/server";
import { EntitiesView } from "./_components/entities-view"; import { EntitiesView } from "./_components/entities-view";
export default async function EntitiesPage({ export default async function EntitiesPage({
@@ -11,16 +10,18 @@ export default async function EntitiesPage({
const params = await searchParams; const params = await searchParams;
const initialTab = params.tab === "businesses" ? "businesses" : "clients"; const initialTab = params.tab === "businesses" ? "businesses" : "clients";
void api.clients.getAll.prefetch(); const [clients, businesses] = await Promise.all([
void api.businesses.getAll.prefetch(); api.clients.getAll(),
api.businesses.getAll(),
]);
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<HydrateClient> <EntitiesView
<Suspense fallback={<DataTableSkeleton columns={5} rows={8} />}> initialTab={initialTab}
<EntitiesView initialTab={initialTab} /> clients={clients}
</Suspense> businesses={businesses}
</HydrateClient> />
</div> </DashboardPage>
); );
} }
+7 -8
View File
@@ -2,7 +2,8 @@
import { useState } from "react"; import { useState } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
import { Button } from "~/components/ui/button"; 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 { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -145,11 +146,10 @@ export default function ExpensesPage() {
.reduce((s, e) => s + e.amount, 0); .reduce((s, e) => s + e.amount, 0);
return ( return (
<div className="page-enter space-y-6 pb-6"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Expenses" title="Expenses"
description="Track billable and non-billable expenses" description="Track billable and non-billable expenses"
variant="gradient"
> >
<Button <Button
onClick={handleOpen} onClick={handleOpen}
@@ -158,10 +158,9 @@ export default function ExpensesPage() {
> >
<Plus className="mr-2 h-5 w-5" /> Add Expense <Plus className="mr-2 h-5 w-5" /> Add Expense
</Button> </Button>
</PageHeader> </DashboardPageHeader>
{/* Summary cards */} <div className={dashboardStatGridClass}>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
@@ -476,6 +475,6 @@ export default function ExpensesPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </DashboardPage>
); );
} }
@@ -1,25 +1,27 @@
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import {
DashboardPage,
dashboardGapClass,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
export function InvoiceDetailsSkeleton() { export function InvoiceDetailsSkeleton() {
return ( return (
<div className="space-y-6 pb-24"> <DashboardPage className="pb-24">
{/* Header */} <DashboardPageHeader
<PageHeader
title="Loading..." title="Loading..."
description="View and manage invoice information" description="View and manage invoice information"
variant="gradient"
> >
<Skeleton className="h-10 w-10 sm:w-32" /> <Skeleton className="h-10 w-10 sm:w-32" />
<Skeleton className="h-10 w-24" /> <Skeleton className="h-10 w-24" />
</PageHeader> </DashboardPageHeader>
{/* Content */} <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
{/* Left Column */}
<div className="space-y-6 lg:col-span-2">
{/* Invoice Header Skeleton */} {/* Invoice Header Skeleton */}
<Card> <Card>
<CardContent className="p-4 sm:p-6"> <CardContent className="p-4 sm:p-6">
@@ -155,7 +157,7 @@ export function InvoiceDetailsSkeleton() {
</div> </div>
{/* Right Column - Actions */} {/* Right Column - Actions */}
<div className="space-y-6"> <div className={cn("flex flex-col", dashboardGapClass)}>
<Card className="lg:sticky lg:top-6"> <Card className="lg:sticky lg:top-6">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -172,6 +174,6 @@ export function InvoiceDetailsSkeleton() {
</Card> </Card>
</div> </div>
</div> </div>
</div> </DashboardPage>
); );
} }
@@ -25,7 +25,7 @@ export function PDFDownloadButton({
{ id: invoiceId }, { id: invoiceId },
{ enabled: false }, { enabled: false },
); );
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, { const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(undefined, {
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -59,11 +59,11 @@ export function PDFDownloadButton({
}; };
await generateInvoicePDF(pdfData, { await generateInvoicePDF(pdfData, {
pdfTemplate: platformTheme?.pdfTemplate, pdfTemplate: pdfSettings?.pdfTemplate,
pdfAccentColor: platformTheme?.pdfAccentColor, pdfAccentColor: pdfSettings?.pdfAccentColor,
pdfFooterText: platformTheme?.pdfFooterText, pdfFooterText: pdfSettings?.pdfFooterText,
pdfShowLogo: platformTheme?.pdfShowLogo, pdfShowLogo: pdfSettings?.pdfShowLogo,
pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers, pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
}); });
toast.success("PDF downloaded successfully"); toast.success("PDF downloaded successfully");
} catch (error) { } catch (error) {
+14 -9
View File
@@ -24,7 +24,13 @@ import { notFound, useParams, useRouter } from "next/navigation";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { StatusBadge } from "~/components/data/status-badge"; import { StatusBadge } from "~/components/data/status-badge";
import { PageHeader } from "~/components/layout/page-header"; import {
DashboardPage,
dashboardGapClass,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -224,11 +230,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
}; };
return ( return (
<div className="page-enter space-y-6 pb-24"> <DashboardPage className="pb-24">
<PageHeader <DashboardPageHeader
title="Invoice Details" title="Invoice Details"
description="View and manage invoice information" description="View and manage invoice information"
variant="gradient"
> >
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" /> <PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
<Button asChild variant="default" className="hover-lift"> <Button asChild variant="default" className="hover-lift">
@@ -237,11 +242,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
Edit Edit
</Link> </Link>
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
{/* Left Column */} {/* Left Column */}
<div className="space-y-6 lg:col-span-2"> <div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
{/* Invoice Header */} {/* Invoice Header */}
<Card> <Card>
<CardContent className="p-4 sm:p-6"> <CardContent className="p-4 sm:p-6">
@@ -531,7 +536,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</div> </div>
{/* Right Column - Actions */} {/* Right Column - Actions */}
<div className="space-y-6"> <div className={cn("flex flex-col", dashboardGapClass)}>
{storedStatus === "draft" && ( {storedStatus === "draft" && (
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} /> <InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
)} )}
@@ -833,7 +838,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </DashboardPage>
); );
} }
+43 -35
View File
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Button } from "~/components/ui/button"; 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
@@ -17,7 +16,20 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import {
DashboardPage,
dashboardGapClass,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { cn } from "~/lib/utils";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { EmailComposer } from "~/components/forms/email-composer"; import { EmailComposer } from "~/components/forms/email-composer";
import { EmailPreview } from "~/components/forms/email-preview"; import { EmailPreview } from "~/components/forms/email-preview";
@@ -36,21 +48,20 @@ import {
function SendEmailPageSkeleton() { function SendEmailPageSkeleton() {
return ( return (
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<PageHeader <DashboardPageHeader
title="Loading..." title="Loading..."
description="Loading invoice email" description="Loading invoice email"
variant="gradient"
/> />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className="space-y-6 lg:col-span-2"> <div className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}>
<div className="bg-muted h-96 animate-pulse" /> <div className="bg-muted h-96 animate-pulse" />
</div> </div>
<div className="space-y-6"> <div className={cn(dashboardGapClass, "flex flex-col")}>
<div className="bg-muted h-64 animate-pulse" /> <div className="bg-muted h-64 animate-pulse" />
</div> </div>
</div> </div>
</div> </DashboardPage>
); );
} }
@@ -280,7 +291,7 @@ export default function SendEmailPage() {
} }
}; };
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com"; const fromEmail = invoice?.business?.email ?? NOREPLY_EMAIL;
const toEmail = invoice?.client?.email ?? ""; const toEmail = invoice?.client?.email ?? "";
const canSend = const canSend =
@@ -292,18 +303,18 @@ export default function SendEmailPage() {
if (!invoice) { if (!invoice) {
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertDescription>Invoice not found.</AlertDescription> <AlertDescription>Invoice not found.</AlertDescription>
</Alert> </Alert>
</div> </DashboardPage>
); );
} }
return ( return (
<div className="page-enter space-y-6 pb-32"> <DashboardPage className="pb-32">
<PageHeader <DashboardPageHeader
title={`Send Invoice ${invoice.invoiceNumber}`} title={`Send Invoice ${invoice.invoiceNumber}`}
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat( description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat(
"en-US", "en-US",
@@ -313,7 +324,6 @@ export default function SendEmailPage() {
day: "numeric", day: "numeric",
}, },
).format(new Date())}`} ).format(new Date())}`}
variant="gradient"
> >
<Button <Button
variant="outline" variant="outline"
@@ -322,7 +332,7 @@ export default function SendEmailPage() {
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Back to Invoice Back to Invoice
</Button> </Button>
</PageHeader> </DashboardPageHeader>
{/* Warning for missing email */} {/* Warning for missing email */}
{(!toEmail || toEmail.trim() === "") && ( {(!toEmail || toEmail.trim() === "") && (
@@ -336,23 +346,22 @@ export default function SendEmailPage() {
)} )}
{/* Main Content */} {/* Main Content */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Tabs value={activeTab} onValueChange={setActiveTab}> <PageTabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2"> <PageTabsList>
<TabsTrigger value="compose" className="flex items-center gap-2"> <PageTabsTrigger value="compose" className="gap-2">
<Edit3 className="h-4 w-4" /> <Edit3 className="h-4 w-4" />
Compose Compose
</TabsTrigger> </PageTabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2"> <PageTabsTrigger value="preview" className="gap-2">
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
Preview Preview
</TabsTrigger> </PageTabsTrigger>
</TabsList> </PageTabsList>
<div className="mt-6"> <PageTabsContent value="compose">
<TabsContent value="compose" className="space-y-6"> <Card>
<Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" /> <Mail className="h-5 w-5" />
@@ -387,10 +396,10 @@ export default function SendEmailPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
<TabsContent value="preview" className="space-y-6"> <PageTabsContent value="preview">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" /> <Eye className="h-5 w-5" />
@@ -413,13 +422,12 @@ export default function SendEmailPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
</div> </PageTabs>
</Tabs>
</div> </div>
{/* Sidebar */} {/* Sidebar */}
<div className="space-y-6"> <div className={cn(dashboardGapClass, "flex flex-col")}>
{/* Invoice Summary */} {/* Invoice Summary */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -644,6 +652,6 @@ export default function SendEmailPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </DashboardPage>
); );
} }
+8 -7
View File
@@ -10,7 +10,9 @@ import {
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { CSVImportPage } from "~/components/csv-import-page"; import { CSVImportPage } from "~/components/csv-import-page";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGridClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -19,7 +21,7 @@ import { HydrateClient } from "~/trpc/server";
// File Upload Instructions Component // File Upload Instructions Component
function FormatInstructions() { function FormatInstructions() {
return ( return (
<div className="grid gap-6 lg:grid-cols-2"> <div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
{/* Required Format */} {/* Required Format */}
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
@@ -203,11 +205,10 @@ function FileFormatHelp() {
export default async function ImportPage() { export default async function ImportPage() {
return ( return (
<div className="space-y-8"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Import Time Entries" title="Import Time Entries"
description="Upload CSV files to create invoices from your time tracking data" description="Upload CSV files to create invoices from your time tracking data"
variant="gradient"
> >
<Link href="/dashboard/invoices"> <Link href="/dashboard/invoices">
<Button variant="outline" size="lg"> <Button variant="outline" size="lg">
@@ -215,7 +216,7 @@ export default async function ImportPage() {
Back to Invoices Back to Invoices
</Button> </Button>
</Link> </Link>
</PageHeader> </DashboardPageHeader>
<HydrateClient> <HydrateClient>
{/* Main CSV Import Component */} {/* Main CSV Import Component */}
@@ -230,6 +231,6 @@ export default async function ImportPage() {
{/* Important Notes */} {/* Important Notes */}
<ImportantNotes /> <ImportantNotes />
</HydrateClient> </HydrateClient>
</div> </DashboardPage>
); );
} }
+6 -6
View File
@@ -2,7 +2,8 @@ import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { api, HydrateClient } from "~/trpc/server"; import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { FileText, Plus, Upload } from "lucide-react"; import { FileText, Plus, Upload } from "lucide-react";
import { InvoicesDataTable } from "./_components/invoices-data-table"; import { InvoicesDataTable } from "./_components/invoices-data-table";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DataTableSkeleton } from "~/components/data/data-table";
@@ -16,11 +17,10 @@ async function InvoicesTable() {
export default async function InvoicesPage() { export default async function InvoicesPage() {
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Invoices" title="Invoices"
description="Manage your invoices and track payments" description="Manage your invoices and track payments"
variant="gradient"
> >
<Button asChild variant="outline" className="hover-lift shadow-sm"> <Button asChild variant="outline" className="hover-lift shadow-sm">
<Link href="/dashboard/invoices/import"> <Link href="/dashboard/invoices/import">
@@ -40,13 +40,13 @@ export default async function InvoicesPage() {
<span>Create Invoice</span> <span>Create Invoice</span>
</Link> </Link>
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<HydrateClient> <HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}> <Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
<InvoicesTable /> <InvoicesTable />
</Suspense> </Suspense>
</HydrateClient> </HydrateClient>
</div> </DashboardPage>
); );
} }
@@ -13,7 +13,8 @@ import {
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
@@ -358,17 +359,16 @@ export default function RecurringInvoicesPage() {
const isSubmitting = create.isPending || update.isPending; const isSubmitting = create.isPending || update.isPending;
return ( return (
<div className="page-enter space-y-6 pb-24"> <DashboardPage className="pb-24">
<PageHeader <DashboardPageHeader
title="Recurring Invoices" title="Recurring Invoices"
description="Schedule automatic invoice generation" description="Schedule automatic invoice generation"
variant="gradient"
> >
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}> <Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
New recurring New recurring
</Button> </Button>
</PageHeader> </DashboardPageHeader>
{isLoading ? ( {isLoading ? (
<div className="flex h-48 items-center justify-center"> <div className="flex h-48 items-center justify-center">
@@ -529,6 +529,6 @@ export default function RecurringInvoicesPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </DashboardPage>
); );
} }
+24 -18
View File
@@ -2,7 +2,14 @@
import { useState } from "react"; import { useState } from "react";
import { api, type RouterOutputs } from "~/trpc/react"; import { api, type RouterOutputs } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -18,7 +25,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react"; import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
@@ -187,25 +194,24 @@ export default function TemplatesPage() {
const termsTemplates = templates.filter((t) => t.type === "terms"); const termsTemplates = templates.filter((t) => t.type === "terms");
return ( return (
<div className="page-enter space-y-6 pb-6"> <DashboardPage className="pb-6">
<PageHeader <DashboardPageHeader
title="Invoice Templates" title="Invoice Templates"
description="Reusable notes and payment terms for your invoices" description="Reusable notes and payment terms for your invoices"
variant="gradient"
/> />
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}> <PageTabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<TabsList className="grid w-full grid-cols-2"> <PageTabsList>
<TabsTrigger value="notes"> <PageTabsTrigger value="notes">
<FileText className="mr-1.5 h-4 w-4" /> Notes ( <FileText className="mr-1.5 h-4 w-4" /> Notes (
{notesTemplates.length}) {notesTemplates.length})
</TabsTrigger> </PageTabsTrigger>
<TabsTrigger value="terms"> <PageTabsTrigger value="terms">
<FileText className="mr-1.5 h-4 w-4" /> Terms ( <FileText className="mr-1.5 h-4 w-4" /> Terms (
{termsTemplates.length}) {termsTemplates.length})
</TabsTrigger> </PageTabsTrigger>
</TabsList> </PageTabsList>
<TabsContent value="notes" className="mt-4"> <PageTabsContent value="notes">
<TemplateList <TemplateList
items={notesTemplates} items={notesTemplates}
type="notes" type="notes"
@@ -214,8 +220,8 @@ export default function TemplatesPage() {
onEdit={handleEdit} onEdit={handleEdit}
onDelete={setDeleteId} onDelete={setDeleteId}
/> />
</TabsContent> </PageTabsContent>
<TabsContent value="terms" className="mt-4"> <PageTabsContent value="terms">
<TemplateList <TemplateList
items={termsTemplates} items={termsTemplates}
type="terms" type="terms"
@@ -224,8 +230,8 @@ export default function TemplatesPage() {
onEdit={handleEdit} onEdit={handleEdit}
onDelete={setDeleteId} onDelete={setDeleteId}
/> />
</TabsContent> </PageTabsContent>
</Tabs> </PageTabs>
{/* Create/Edit dialog */} {/* Create/Edit dialog */}
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -320,6 +326,6 @@ export default function TemplatesPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </DashboardPage>
); );
} }
+18 -1
View File
@@ -1,7 +1,11 @@
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AppProviders } from "~/components/providers/app-providers"; import { AppProviders } from "~/components/providers/app-providers";
import { DashboardShell } from "~/components/layout/dashboard-shell"; import { DashboardShell } from "~/components/layout/dashboard-shell";
import { DashboardUserProvider } from "~/components/layout/dashboard-user-context";
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server"; import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -16,9 +20,22 @@ export default async function DashboardLayout({
redirect("/auth/signin?callbackUrl=/dashboard"); redirect("/auth/signin?callbackUrl=/dashboard");
} }
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
columns: {
role: true,
onboardingCompletedAt: true,
},
});
const isAdmin = user?.role === "admin";
const needsOnboarding = user?.onboardingCompletedAt == null;
return ( return (
<AppProviders> <AppProviders>
<DashboardShell>{children}</DashboardShell> <DashboardUserProvider isAdmin={isAdmin} needsOnboarding={needsOnboarding}>
<DashboardShell>{children}</DashboardShell>
</DashboardUserProvider>
</AppProviders> </AppProviders>
); );
} }
@@ -0,0 +1,284 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
ArrowRight,
Building2,
CheckCircle2,
Sparkles,
Users,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { api } from "~/trpc/react";
type Step = "welcome" | "business" | "client" | "done";
export function OnboardingWizard() {
const router = useRouter();
const utils = api.useUtils();
const { data: status, isLoading } = api.settings.getOnboardingStatus.useQuery();
const [step, setStep] = useState<Step>("welcome");
const [businessName, setBusinessName] = useState("");
const [clientName, setClientName] = useState("");
const createBusiness = api.businesses.create.useMutation({
onSuccess: async () => {
toast.success("Business added");
await utils.settings.getOnboardingStatus.invalidate();
setStep("client");
},
onError: (error) => toast.error(error.message),
});
const createClient = api.clients.create.useMutation({
onSuccess: async () => {
toast.success("Client added");
await utils.settings.getOnboardingStatus.invalidate();
setStep("done");
},
onError: (error) => toast.error(error.message),
});
const completeOnboarding = api.settings.completeOnboarding.useMutation({
onSuccess: () => {
router.push("/dashboard");
router.refresh();
},
onError: (error) => toast.error(error.message),
});
useEffect(() => {
if (status?.completed) {
router.replace("/dashboard");
}
}, [status?.completed, router]);
useEffect(() => {
if (!isLoading && status && !status.completed && step === "welcome") {
if (status.businessCount > 0 && status.clientCount > 0) {
setStep("done");
} else if (status.businessCount > 0) {
setStep("client");
}
}
}, [isLoading, status, step]);
function handleSkip() {
completeOnboarding.mutate();
}
function handleBusinessSubmit(e: React.FormEvent) {
e.preventDefault();
if (!businessName.trim()) {
toast.error("Business name is required");
return;
}
createBusiness.mutate({
name: businessName.trim(),
isDefault: true,
});
}
function handleClientSubmit(e: React.FormEvent) {
e.preventDefault();
if (!clientName.trim()) {
toast.error("Client name is required");
return;
}
createClient.mutate({ name: clientName.trim() });
}
function handleFinish() {
completeOnboarding.mutate();
}
function handleCreateInvoice() {
completeOnboarding.mutate(undefined, {
onSuccess: () => {
router.push("/dashboard/invoices/new");
router.refresh();
},
});
}
if (isLoading || status?.completed) {
return (
<div className="mx-auto flex min-h-[60vh] max-w-lg items-center justify-center">
<p className="text-muted-foreground text-sm">Loading</p>
</div>
);
}
return (
<div className="mx-auto flex min-h-[60vh] w-full max-w-lg flex-col justify-center py-8">
<div className="mb-6 flex items-center justify-center gap-2">
<Sparkles className="text-primary h-5 w-5" />
<p className="text-muted-foreground text-sm font-medium">
{step === "welcome" && "Step 1 of 3"}
{step === "business" && "Step 2 of 3"}
{step === "client" && "Step 3 of 3"}
{step === "done" && "All set"}
</p>
</div>
{step === "welcome" && (
<Card>
<CardHeader>
<CardTitle>Welcome to BeenVoice</CardTitle>
<CardDescription>
Let&apos;s set up the basics so you can send your first invoice.
This only takes a minute.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<Building2 className="text-primary mt-0.5 h-4 w-4 shrink-0" />
<p>Add the business you send invoices from</p>
</div>
<div className="flex items-start gap-3">
<Users className="text-primary mt-0.5 h-4 w-4 shrink-0" />
<p>Add your first client to bill</p>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button className="flex-1" onClick={() => setStep("business")}>
Get started
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button
variant="ghost"
onClick={handleSkip}
disabled={completeOnboarding.isPending}
>
Skip for now
</Button>
</div>
</CardContent>
</Card>
)}
{step === "business" && (
<Card>
<CardHeader>
<CardTitle>Your business</CardTitle>
<CardDescription>
This appears on invoices as the sender name, logo, and contact
details.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleBusinessSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="business-name">Business name</Label>
<Input
id="business-name"
value={businessName}
onChange={(e) => setBusinessName(e.target.value)}
placeholder="Acme Studio LLC"
autoFocus
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
className="flex-1"
disabled={createBusiness.isPending}
>
Continue
</Button>
<Button type="button" variant="ghost" onClick={handleSkip}>
Skip for now
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{step === "client" && (
<Card>
<CardHeader>
<CardTitle>Your first client</CardTitle>
<CardDescription>
Who are you billing? You can add more details later.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleClientSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client-name">Client name</Label>
<Input
id="client-name"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="Acme Corp"
autoFocus
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
className="flex-1"
disabled={createClient.isPending}
>
Continue
</Button>
<Button type="button" variant="ghost" onClick={handleSkip}>
Skip for now
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{step === "done" && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="text-primary h-5 w-5" />
You&apos;re ready to go
</CardTitle>
<CardDescription>
Your workspace is set up. Create an invoice or explore the
dashboard.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2 sm:flex-row">
<Button className="flex-1" onClick={handleFinish}>
Go to dashboard
</Button>
<Button variant="outline" className="flex-1" onClick={handleCreateInvoice}>
Create first invoice
</Button>
</CardContent>
</Card>
)}
{step !== "welcome" && step !== "done" && (
<Button
variant="link"
className="text-muted-foreground mt-4"
onClick={() =>
setStep(step === "client" ? "business" : "welcome")
}
>
Back
</Button>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { DashboardPage } from "~/components/layout/dashboard-page";
import { OnboardingWizard } from "./_components/onboarding-wizard";
export default function OnboardingPage() {
return (
<DashboardPage>
<OnboardingWizard />
</DashboardPage>
);
}
+190 -308
View File
@@ -10,24 +10,34 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Suspense } from "react"; import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
import {
InvoiceStatusChart,
MonthlyMetricsChart,
RevenueChart,
} from "~/app/dashboard/_components/charts-client";
import { DashboardPageHeader } from "~/components/layout/page-header";
import {
DashboardCardTitle,
DashboardGrid,
DashboardPage as DashboardPageLayout,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import {
import { Skeleton } from "~/components/ui/skeleton"; Card,
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server"; import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
import { HydrateClient, api } from "~/trpc/server"; import { cn } from "~/lib/utils";
import type { StoredInvoiceStatus } from "~/types/invoice"; import { api } from "~/trpc/server";
import { RevenueChart, InvoiceStatusChart, MonthlyMetricsChart } from "~/app/dashboard/_components/charts-client";
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
import type { DashboardStats, RecentInvoice } from "./types"; import type { DashboardStats, RecentInvoice } from "./types";
// Hero section with clean mono design
// Enhanced stats cards with better visuals
function DashboardStats({ stats }: { stats: DashboardStats }) { function DashboardStats({ stats }: { stats: DashboardStats }) {
// TODO: Import RouterOutput type
const formatTrend = (value: number, isCount = false) => { const formatTrend = (value: number, isCount = false) => {
if (isCount) { if (isCount) {
return value > 0 ? `+${value}` : value.toString(); return value > 0 ? `+${value}` : value.toString();
@@ -44,42 +54,42 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
change: formatTrend(stats.revenueChange), change: formatTrend(stats.revenueChange),
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const), trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" as const),
iconName: "DollarSign" as const, iconName: "DollarSign" as const,
description: "Total collected revenue", description: "Collected to date",
}, },
{ {
title: "Pending Amount", title: "Pending",
value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`, value: `$${stats.pendingAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
numericValue: stats.pendingAmount, numericValue: stats.pendingAmount,
isCurrency: true, isCurrency: true,
change: "0%", // TODO: Calculate pending change if needed change: "0%",
trend: "neutral" as const, trend: "neutral" as const,
iconName: "Clock" as const, iconName: "Clock" as const,
description: "Invoices awaiting payment", description: "Awaiting payment",
}, },
{ {
title: "Active Clients", title: "Clients",
value: stats.totalClients.toString(), value: stats.totalClients.toString(),
numericValue: stats.totalClients, numericValue: stats.totalClients,
isCurrency: false, isCurrency: false,
change: "0", // TODO: Calculate client change if needed change: "0",
trend: "neutral" as const, trend: "neutral" as const,
iconName: "Users" as const, iconName: "Users" as const,
description: "Total registered clients", description: "Active clients",
}, },
{ {
title: "Overdue Invoices", title: "Overdue",
value: stats.overdueCount.toString(), value: stats.overdueCount.toString(),
numericValue: stats.overdueCount, numericValue: stats.overdueCount,
isCurrency: false, isCurrency: false,
change: "0", // TODO: Calculate overdue change if needed change: "0",
trend: "neutral" as const, trend: "neutral" as const,
iconName: "TrendingDown" as const, iconName: "TrendingDown" as const,
description: "Invoices past due date", description: "Past due date",
}, },
]; ];
return ( return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"> <div className={cn(dashboardGridClass, "sm:grid-cols-2 xl:grid-cols-4")}>
{statCards.map((stat, index) => ( {statCards.map((stat, index) => (
<AnimatedStatsCard <AnimatedStatsCard
key={stat.title} key={stat.title}
@@ -98,21 +108,15 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
); );
} }
// Charts section function ChartsSection({ stats }: { stats: DashboardStats }) {
async function ChartsSection({ stats }: { stats: DashboardStats }) {
// We still fetch all invoices for the status chart for now, or we could aggregate that too.
// For now, let's keep status chart as is (fetching all) but use aggregated for revenue.
// Actually, let's fetch invoices here for the status chart to keep it working.
const invoices = await api.invoices.getAll();
return ( return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <DashboardGrid className="lg:grid-cols-2">
{/* Revenue Trend Chart */}
<Card className="lg:col-span-2"> <Card className="lg:col-span-2">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>
<BarChart3 className="h-5 w-5" /> <DashboardCardTitle icon={BarChart3}>
Revenue Over Time Revenue over time
</DashboardCardTitle>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -120,55 +124,54 @@ async function ChartsSection({ stats }: { stats: DashboardStats }) {
</CardContent> </CardContent>
</Card> </Card>
{/* Invoice Status Breakdown */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>
<Activity className="h-5 w-5" /> <DashboardCardTitle icon={Activity}>
Invoice Status Invoice status
</DashboardCardTitle>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<InvoiceStatusChart invoices={invoices} /> <InvoiceStatusChart data={stats.statusChartData} />
</CardContent> </CardContent>
</Card> </Card>
{/* Monthly Metrics */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>
<Calendar className="h-5 w-5" /> <DashboardCardTitle icon={Calendar}>
Monthly Metrics Monthly metrics
</DashboardCardTitle>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<MonthlyMetricsChart invoices={invoices} /> <MonthlyMetricsChart data={stats.monthlyMetricsChartData} />
</CardContent> </CardContent>
</Card> </Card>
</div> </DashboardGrid>
); );
} }
// Enhanced Quick Actions
function QuickActions() { function QuickActions() {
const actions = [ const actions = [
{ {
title: "Create Invoice", title: "Create invoice",
description: "Start a new invoice for a client", description: "Start a new invoice for a client",
href: "/dashboard/invoices/new", href: "/dashboard/invoices/new",
icon: FileText, icon: FileText,
featured: true, featured: true,
}, },
{ {
title: "Add Client", title: "Add client",
description: "Register a new client", description: "Register someone you bill",
href: "/dashboard/clients/new", href: "/dashboard/clients/new",
icon: Users, icon: Users,
featured: false, featured: false,
}, },
{ {
title: "View All Invoices", title: "View invoices",
description: "Manage your invoice pipeline", description: "Browse your full pipeline",
href: "/dashboard/invoices", href: "/dashboard/invoices",
icon: BarChart3, icon: BarChart3,
featured: false, featured: false,
@@ -178,27 +181,36 @@ function QuickActions() {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>
<Plus className="h-5 w-5" /> <DashboardCardTitle icon={Plus}>Quick actions</DashboardCardTitle>
Quick Actions
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-2">
{actions.map((action) => { {actions.map((action) => {
const Icon = action.icon; const Icon = action.icon;
return ( return (
<Link <Link
key={action.title} key={action.title}
href={action.href} href={action.href}
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${ className={cn(
"flex items-start gap-3 rounded-2xl border p-4 transition-colors",
action.featured action.featured
? "border-foreground/20 bg-muted/50 hover:bg-muted" ? "border-primary/20 bg-primary/5 hover:bg-primary/10"
: "border-border bg-background hover:bg-muted/50" : "border-border/60 bg-background/50 hover:bg-muted/50",
}`} )}
> >
<Icon className="h-5 w-5 flex-shrink-0" /> <div
className={cn(
"rounded-xl p-2",
action.featured
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-semibold">{action.title}</p> <p className="text-sm font-medium">{action.title}</p>
<p className="text-muted-foreground text-sm leading-relaxed"> <p className="text-muted-foreground text-sm leading-relaxed">
{action.description} {action.description}
</p> </p>
@@ -211,204 +223,168 @@ function QuickActions() {
); );
} }
// Current work section with enhanced design function CurrentWork({
async function CurrentWork() { currentDraft,
const invoices = await api.invoices.getAll(); }: {
const draftInvoices = invoices.filter( currentDraft: DashboardStats["currentDraft"];
(invoice) => }) {
getEffectiveInvoiceStatus( if (!currentDraft) {
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) === "draft",
);
const currentInvoice = draftInvoices[0];
if (!currentInvoice) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>
<Activity className="h-5 w-5" /> <DashboardCardTitle icon={Activity}>
Current Work Current work
</DashboardCardTitle>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex flex-col items-center py-8 text-center">
<div className="py-8 text-center"> <div className="bg-muted mb-4 rounded-2xl p-3">
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <FileText className="text-muted-foreground h-6 w-6" />
<h3 className="mb-2 text-lg font-semibold">No active drafts</h3>
<p className="text-muted-foreground mb-4">
Create a new invoice to get started
</p>
<Button asChild variant="outline" className="border-foreground/20">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create Invoice
</Link>
</Button>
</div> </div>
<p className="font-medium">No draft in progress</p>
<CardDescription className="mt-1 max-w-xs">
Start an invoice when you&apos;re ready to bill your next piece of
work.
</CardDescription>
<Button asChild variant="outline" className="mt-5">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create invoice
</Link>
</Button>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
const totalHours = const totalHours = currentDraft.totalHours;
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2"> <CardTitle>
<Activity className="h-5 w-5" /> <DashboardCardTitle icon={Activity}>Current work</DashboardCardTitle>
Current Work
</CardTitle> </CardTitle>
<Badge variant="secondary">In Progress</Badge> <Badge variant="secondary">Draft</Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-5">
<div className="space-y-4"> <div className="space-y-1">
<div className="space-y-2"> <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div>
<h3 className="text-lg font-semibold break-words"> <p className="font-medium">#{currentDraft.invoiceNumber}</p>
#{currentInvoice.invoiceNumber} <p className="text-muted-foreground text-sm">
</h3> {currentDraft.client?.name}
<span className="text-primary text-2xl font-bold"> </p>
${currentInvoice.totalAmount.toFixed(2)}
</span>
</div>
<div className="text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between">
<span className="break-words">{currentInvoice.client?.name}</span>
<span className="text-xs sm:text-sm">
{totalHours.toFixed(1)} hours logged
</span>
</div> </div>
<p className="font-mono text-xl font-semibold tabular-nums">
${currentDraft.totalAmount.toFixed(2)}
</p>
</div> </div>
<p className="text-muted-foreground font-mono text-xs tabular-nums">
{totalHours.toFixed(1)} hours logged
</p>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button asChild variant="outline" size="sm" className="flex-1">
asChild <Link href={`/dashboard/invoices/${currentDraft.id}`}>
variant="outline" <Eye className="mr-2 h-4 w-4" />
size="sm" View
className="hover-lift flex-1" </Link>
> </Button>
<Link href={`/dashboard/invoices/${currentInvoice.id}`}> <Button asChild size="sm" className="flex-1">
<Eye className="mr-2 h-4 w-4" /> <Link href={`/dashboard/invoices/${currentDraft.id}/edit`}>
View <Edit className="mr-2 h-4 w-4" />
</Link> Continue
</Button> </Link>
<Button asChild size="sm" className="hover-lift flex-1"> </Button>
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Continue
</Link>
</Button>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
// Enhanced recent activity function RecentActivity({
async function RecentActivity({
recentInvoices, recentInvoices,
}: { }: {
recentInvoices: RecentInvoice[]; recentInvoices: RecentInvoice[];
}) { }) {
// Use passed recentInvoices instead of fetching all const getStatusVariant = (status: string) => {
const getStatusStyle = (status: string) => {
switch (status) { switch (status) {
case "paid": case "paid":
return { return "default" as const;
backgroundColor: "oklch(var(--chart-2) / 0.1)",
borderColor: "oklch(var(--chart-2) / 0.3)",
color: "oklch(var(--chart-2))",
};
case "sent": case "sent":
return { return "secondary" as const;
backgroundColor: "oklch(var(--chart-1) / 0.1)",
borderColor: "oklch(var(--chart-1) / 0.3)",
color: "oklch(var(--chart-1))",
};
case "overdue": case "overdue":
return { return "destructive" as const;
backgroundColor: "oklch(var(--chart-3) / 0.1)",
borderColor: "oklch(var(--chart-3) / 0.3)",
color: "oklch(var(--chart-3))",
};
default: default:
return { return "outline" as const;
backgroundColor: "hsl(var(--muted))",
borderColor: "hsl(var(--border))",
color: "hsl(var(--muted-foreground))",
};
} }
}; };
return ( return (
<Card> <Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2"> <CardTitle>
<Calendar className="h-5 w-5" /> <DashboardCardTitle icon={Calendar}>
Recent Activity Recent activity
</DashboardCardTitle>
</CardTitle> </CardTitle>
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
<Link href="/dashboard/invoices"> <Link href="/dashboard/invoices">
<span className="hidden sm:inline">View All</span> <span className="hidden sm:inline">View all</span>
<ArrowUpRight className="h-4 w-4 sm:ml-1" /> <ArrowUpRight className="h-4 w-4 sm:ml-1" />
</Link> </Link>
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{recentInvoices.length === 0 ? ( {recentInvoices.length === 0 ? (
<div className="py-8 text-center"> <div className="flex flex-col items-center py-8 text-center">
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> <div className="bg-muted mb-4 rounded-2xl p-3">
<h3 className="mb-2 text-lg font-semibold">No invoices yet</h3> <FileText className="text-muted-foreground h-6 w-6" />
<p className="text-muted-foreground mb-4"> </div>
Create your first invoice to get started <p className="font-medium">No invoices yet</p>
</p> <CardDescription className="mt-1 max-w-xs">
<Button asChild variant="outline" className="border-foreground/20"> Your latest invoices will show up here.
</CardDescription>
<Button asChild variant="outline" className="mt-5">
<Link href="/dashboard/invoices/new"> <Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Create Your First Invoice Create invoice
</Link> </Link>
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-2">
{recentInvoices.map((invoice, _index) => ( {recentInvoices.map((invoice) => (
<Link <Link
key={invoice.id} key={invoice.id}
href={`/dashboard/invoices/${invoice.id}`} href={`/dashboard/invoices/${invoice.id}`}
className="block" className="hover:bg-muted/50 border-border/60 flex items-center gap-3 rounded-2xl border p-3 transition-colors"
> >
<div className="recent-activity-item bg-muted/50 hover:bg-muted border-foreground/20 rounded-lg border p-3 transition-colors"> <div className="bg-muted rounded-xl p-2">
<div className="flex items-start gap-3"> <FileText className="text-muted-foreground h-4 w-4" />
<div className="bg-muted flex-shrink-0 rounded-lg p-2"> </div>
<FileText className="text-muted-foreground h-4 w-4" /> <div className="min-w-0 flex-1">
</div> <div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1 space-y-2"> <p className="truncate text-sm font-medium">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> #{invoice.invoiceNumber}
<div className="min-w-0"> </p>
<p className="truncate font-medium"> <span className="shrink-0 font-mono text-sm font-medium tabular-nums">
#{invoice.invoiceNumber} ${invoice.totalAmount.toFixed(2)}
</p> </span>
<p className="text-muted-foreground truncate text-sm"> </div>
{invoice.client?.name} <div className="mt-1 flex items-center justify-between gap-2">
</p> <p className="text-muted-foreground truncate text-xs">
</div> {invoice.client?.name}
<div className="flex flex-shrink-0 items-center gap-2"> </p>
<Badge style={getStatusStyle(invoice.status)}> <Badge
{invoice.status} variant={getStatusVariant(invoice.status)}
</Badge> className="shrink-0 text-[10px]"
<span className="text-primary font-semibold"> >
${invoice.totalAmount.toFixed(2)} {invoice.status}
</span> </Badge>
</div>
</div>
<p className="text-muted-foreground text-xs">
{new Date(invoice.issueDate).toLocaleDateString()}
</p>
</div>
</div> </div>
</div> </div>
</Link> </Link>
@@ -420,121 +396,27 @@ async function RecentActivity({
); );
} }
// Loading skeletons
function StatsSkeleton() {
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-12" />
</div>
<Skeleton className="mb-2 h-8 w-20" />
<Skeleton className="h-3 w-32" />
</CardContent>
</Card>
))}
</div>
);
}
function ChartsSkeleton() {
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card className="lg:col-span-2">
<CardHeader>
<Skeleton className="h-6 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-36" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
);
}
function CardSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</CardContent>
</Card>
);
}
import { DashboardPageHeader } from "~/components/layout/page-header";
// ... imports
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await getOptionalServerSessionFromHeaders(); const session = await getOptionalServerSessionFromHeaders();
const firstName = session?.user?.name?.split(" ")[0] ?? "User"; const firstName = session?.user?.name?.split(" ")[0] ?? "User";
// Fetch stats centrally
const stats = await api.dashboard.getStats(); const stats = await api.dashboard.getStats();
void api.timeEntries.getRunning.prefetch();
return ( return (
<div className="page-enter space-y-6"> <DashboardPageLayout>
<DashboardPageHeader <DashboardPageHeader
title={`Welcome back, ${firstName}!`} title={`Welcome back, ${firstName}`}
description="Here's what's happening with your business today" description="A snapshot of your invoices, revenue, and work in progress."
/> />
<HydrateClient> <DashboardStats stats={stats} />
<Suspense fallback={<StatsSkeleton />}> <ChartsSection stats={stats} />
<DashboardStats stats={stats} /> <DashboardGrid className="lg:grid-cols-2">
</Suspense> <div className={cn(dashboardGridClass)}>
</HydrateClient> <CurrentWork currentDraft={stats.currentDraft} />
<HydrateClient>
<Suspense fallback={<ChartsSkeleton />}>
<ChartsSection stats={stats} />
</Suspense>
</HydrateClient>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="space-y-6">
<HydrateClient>
<Suspense fallback={<CardSkeleton />}>
<CurrentWork />
</Suspense>
</HydrateClient>
<QuickActions /> <QuickActions />
</div> </div>
<RecentActivity recentInvoices={stats.recentInvoices} />
<HydrateClient> </DashboardGrid>
<Suspense fallback={<CardSkeleton />}> </DashboardPageLayout>
<RecentActivity recentInvoices={stats.recentInvoices} />
</Suspense>
</HydrateClient>
</div>
</div>
); );
} }
+30 -26
View File
@@ -2,7 +2,8 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { StatusBadge } from "~/components/data/status-badge"; import { StatusBadge } from "~/components/data/status-badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -14,7 +15,12 @@ import {
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import { formatCurrency } from "~/lib/currency"; import { formatCurrency } from "~/lib/currency";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice"; import type { StoredInvoiceStatus } from "~/types/invoice";
@@ -308,42 +314,40 @@ export default function ReportsPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Reports" title="Reports"
description="Revenue and tax analytics" description="Revenue and tax analytics"
variant="gradient"
/> />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className={dashboardStatGridClass}>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" /> <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
))} ))}
</div> </div>
</div> </DashboardPage>
); );
} }
return ( return (
<div className="page-enter space-y-6 pb-6"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Reports" title="Reports"
description="Revenue and tax analytics" description="Revenue and tax analytics"
variant="gradient"
/> />
<Tabs defaultValue="overview"> <PageTabs defaultValue="overview">
<TabsList className="grid w-full grid-cols-2"> <PageTabsList>
<TabsTrigger value="overview"> <PageTabsTrigger value="overview" className="gap-1.5">
<TrendingUp className="mr-1.5 h-4 w-4" /> Overview <TrendingUp className="h-4 w-4" /> Overview
</TabsTrigger> </PageTabsTrigger>
<TabsTrigger value="tax"> <PageTabsTrigger value="tax" className="gap-1.5">
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary <FileText className="h-4 w-4" /> Tax Summary
</TabsTrigger> </PageTabsTrigger>
</TabsList> </PageTabsList>
{/* ── OVERVIEW TAB ── */} {/* ── OVERVIEW TAB ── */}
<TabsContent value="overview" className="mt-4 space-y-6"> <PageTabsContent value="overview">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className={dashboardStatGridClass}>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -630,10 +634,10 @@ export default function ReportsPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
</TabsContent> </PageTabsContent>
{/* ── TAX SUMMARY TAB ── */} {/* ── TAX SUMMARY TAB ── */}
<TabsContent value="tax" className="mt-4 space-y-6"> <PageTabsContent value="tax">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-medium">Tax Year</span> <span className="text-sm font-medium">Tax Year</span>
@@ -840,8 +844,8 @@ export default function ReportsPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
</Tabs> </PageTabs>
</div> </DashboardPage>
); );
} }
@@ -12,16 +12,13 @@ import {
FileUp, FileUp,
Info, Info,
Key, Key,
Monitor,
Palette, Palette,
Shield, Shield,
Upload, Upload,
User, User,
Users, Users,
Link as LinkIcon, Link as LinkIcon,
Monitor,
PanelLeft,
Paintbrush,
Type,
} from "lucide-react"; } from "lucide-react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { authClient } from "~/lib/auth-client"; import { authClient } from "~/lib/auth-client";
@@ -70,11 +67,17 @@ import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { env } from "~/env"; import { env } from "~/env";
import { Badge } from "~/components/ui/badge";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import { Slider } from "~/components/ui/slider"; import { Slider } from "~/components/ui/slider";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
pageTabsGridClass,
} from "~/components/layout/page-tabs";
import { cn } from "~/lib/utils";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -83,19 +86,8 @@ import {
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { useAppearance } from "~/components/providers/appearance-provider"; import { useAppearance } from "~/components/providers/appearance-provider";
import { import { brand, colorModes } from "~/lib/branding";
bodyFontPreferences, import type { PdfTemplate } from "~/lib/appearance";
brand,
colorModes,
colorThemes,
type ColorTheme,
headingFontPreferences,
interfaceThemes,
radiusPreferences,
sidebarStyles,
themePresets,
type InterfaceTheme,
} from "~/lib/branding";
import { ApiAccessSettings } from "./api-access-settings"; import { ApiAccessSettings } from "./api-access-settings";
const PdfPreviewFrame = dynamic( const PdfPreviewFrame = dynamic(
@@ -110,72 +102,6 @@ const PdfPreviewFrame = dynamic(
}, },
); );
function hslChannelsToHex(channels?: string) {
const [hue, saturation, lightness] =
channels?.match(/[\d.]+/g)?.map(Number) ?? [];
if (
hue === undefined ||
saturation === undefined ||
lightness === undefined
) {
return "#16a34a";
}
const s = saturation / 100;
const l = lightness / 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
const m = l - c / 2;
const [r, g, b] =
hue < 60
? [c, x, 0]
: hue < 120
? [x, c, 0]
: hue < 180
? [0, c, x]
: hue < 240
? [0, x, c]
: hue < 300
? [x, 0, c]
: [c, 0, x];
return `#${[r, g, b]
.map((channel) =>
Math.round((channel + m) * 255)
.toString(16)
.padStart(2, "0"),
)
.join("")}`;
}
function hexToHslChannels(hex: string) {
const normalized = hex.replace("#", "");
const red = parseInt(normalized.slice(0, 2), 16) / 255;
const green = parseInt(normalized.slice(2, 4), 16) / 255;
const blue = parseInt(normalized.slice(4, 6), 16) / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const lightness = (max + min) / 2;
const delta = max - min;
if (delta === 0) {
return `0 0% ${Number((lightness * 100).toFixed(1))}%`;
}
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
const hue =
max === red
? 60 * (((green - blue) / delta) % 6)
: max === green
? 60 * ((blue - red) / delta + 2)
: 60 * ((red - green) / delta + 4);
return `${Number(((hue + 360) % 360).toFixed(1))} ${Number(
(saturation * 100).toFixed(1),
)}% ${Number((lightness * 100).toFixed(1))}%`;
}
function isFullHexColor(value: string) { function isFullHexColor(value: string) {
return /^#[0-9A-Fa-f]{6}$/.test(value); return /^#[0-9A-Fa-f]{6}$/.test(value);
} }
@@ -197,43 +123,28 @@ export function SettingsContent() {
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLinking, setIsLinking] = useState(false); const [isLinking, setIsLinking] = useState(false);
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true; const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const { const { colorMode, updateAppearance, isUpdating: appearanceUpdating } =
interfaceTheme, useAppearance();
bodyFontPreference, const utils = api.useUtils();
headingFontPreference, const { data: pdfSettings } = api.settings.getPdfSettings.useQuery();
radiusPreference, const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({
sidebarStyle, onSuccess: async () => {
colorMode, await utils.settings.getPdfSettings.invalidate();
colorTheme, toast.success("Invoice PDF settings updated");
customColor, },
brandName, onError: (error: { message: string }) => {
brandTagline, toast.error(`Failed to update PDF settings: ${error.message}`);
brandLogoText, },
brandIcon, });
pdfTemplate,
pdfAccentColor, const savePdfSettings = (patch: {
pdfFooterText, pdfTemplate?: PdfTemplate;
pdfShowLogo, pdfAccentColor?: string;
pdfShowPageNumbers, pdfFooterText?: string;
updateAppearance, pdfShowLogo?: boolean;
updateAppearanceDebounced, pdfShowPageNumbers?: boolean;
isUpdating: appearanceUpdating, }) => {
} = useAppearance(); updatePdfSettingsMutation.mutate(patch);
const activePreset = themePresets[interfaceTheme];
const themeModified =
activePreset.bodyFontPreference !== bodyFontPreference ||
activePreset.headingFontPreference !== headingFontPreference ||
activePreset.colorTheme !== colorTheme ||
activePreset.radiusPreference !== radiusPreference ||
activePreset.sidebarStyle !== sidebarStyle ||
activePreset.pdfTemplate !== pdfTemplate ||
activePreset.pdfAccentColor !== pdfAccentColor;
const customColorValue = customColor ?? "142.1 76.2% 36.3%";
const selectAccent = (nextColorTheme: ColorTheme) => {
updateAppearance({
colorTheme: nextColorTheme,
...(nextColorTheme === "custom" ? { customColor: customColorValue } : {}),
});
}; };
const handleLinkAuthentik = async () => { const handleLinkAuthentik = async () => {
@@ -320,8 +231,9 @@ export function SettingsContent() {
const importDataMutation = api.settings.importData.useMutation({ const importDataMutation = api.settings.importData.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
const { imported } = result;
toast.success( toast.success(
`Data imported successfully! Added ${result.imported.clients} clients, ${result.imported.businesses} businesses, and ${result.imported.invoices} invoices.`, `Data imported successfully! Added ${imported.clients} clients, ${imported.businesses} businesses, ${imported.invoices} invoices, ${imported.expenses} expenses, ${imported.timeEntries} time entries, and ${imported.recurringInvoices} recurring invoices.`,
); );
setImportData(""); setImportData("");
setIsImportDialogOpen(false); setIsImportDialogOpen(false);
@@ -494,16 +406,16 @@ export function SettingsContent() {
]; ];
return ( return (
<Tabs defaultValue="general"> <PageTabs defaultValue="general">
<TabsList className="bg-muted/50 grid w-full grid-cols-4"> <PageTabsList>
<TabsTrigger value="general">General</TabsTrigger> <PageTabsTrigger value="general">General</PageTabsTrigger>
<TabsTrigger value="preferences">Preferences</TabsTrigger> <PageTabsTrigger value="preferences">Preferences</PageTabsTrigger>
<TabsTrigger value="data">Data</TabsTrigger> <PageTabsTrigger value="data">Data</PageTabsTrigger>
<TabsTrigger value="api">API</TabsTrigger> <PageTabsTrigger value="api">API</PageTabsTrigger>
</TabsList> </PageTabsList>
<TabsContent value="general" className="space-y-8"> <PageTabsContent value="general">
<div className="grid gap-6 lg:grid-cols-2"> <div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
{/* Profile Section */} {/* Profile Section */}
<Card className="form-section bg-card border-border border"> <Card className="form-section bg-card border-border border">
<CardHeader> <CardHeader>
@@ -724,9 +636,9 @@ export function SettingsContent() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
<TabsContent value="preferences" className="space-y-8"> <PageTabsContent value="preferences">
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center gap-2"> <CardTitle className="text-foreground flex items-center gap-2">
@@ -734,448 +646,49 @@ export function SettingsContent() {
Appearance Appearance
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Select the app skin, color mode, accent, and font stack. Choose light, dark, or match your system setting.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
{!isAdmin ? ( <CardContent className="space-y-4">
<CardContent> <div className="max-w-sm space-y-2">
<p className="text-muted-foreground text-sm"> <Label className="flex items-center gap-2">
Platform appearance and branding are managed by an <Monitor className="h-4 w-4" />
administrator. Color mode
</Label>
<Select
value={colorMode}
onValueChange={(value) =>
updateAppearance({
colorMode: value as typeof colorMode,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{colorModes.map((modeOption) => (
<SelectItem
key={modeOption.value}
value={modeOption.value}
>
{modeOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
{
colorModes.find(
(modeOption) => modeOption.value === colorMode,
)?.description
}
</p> </p>
</CardContent> </div>
) : ( {appearanceUpdating && (
<CardContent className="space-y-8"> <p className="text-muted-foreground text-xs">Saving...</p>
<section className="space-y-4"> )}
<div> </CardContent>
<h3 className="text-sm font-medium">Brand</h3>
<p className="text-muted-foreground text-xs">
Public-facing name, logo text, and short product tagline.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Brand Name</Label>
<Input
value={brandName}
onChange={(event) =>
updateAppearanceDebounced({
brandName: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label>Logo Text</Label>
<Input
value={brandLogoText}
onChange={(event) =>
updateAppearanceDebounced({
brandLogoText: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label>Brand Icon</Label>
<Input
value={brandIcon}
onChange={(event) =>
updateAppearanceDebounced({
brandIcon: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label>Tagline</Label>
<Input
value={brandTagline}
onChange={(event) =>
updateAppearanceDebounced({
brandTagline: event.target.value,
})
}
/>
</div>
</div>
</section>
<section className="space-y-4 border-t pt-6">
<div>
<h3 className="text-sm font-medium">Theme</h3>
<p className="text-muted-foreground text-xs">
Presets establish the broad visual language; color mode and
accent can still be tuned independently.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label className="flex items-center gap-2">
<Paintbrush className="h-4 w-4" />
Theme Preset
</Label>
<div className="flex items-center gap-2">
{themeModified && (
<Badge variant="secondary" className="shrink-0">
modified
</Badge>
)}
{themeModified && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => updateAppearance(activePreset)}
>
Reset
</Button>
)}
</div>
</div>
<Select
value={interfaceTheme}
onValueChange={(value) => {
const nextTheme = value as InterfaceTheme;
updateAppearance(themePresets[nextTheme]);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{interfaceThemes.map((themeOption) => (
<SelectItem
key={themeOption.value}
value={themeOption.value}
>
{themeOption.label}
{themeOption.value === interfaceTheme &&
themeModified
? " (modified)"
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
Applies the theme, fonts, accent, corner radius,
navigation chrome, and PDF defaults.
</p>
<p className="text-muted-foreground text-xs leading-snug">
{
interfaceThemes.find(
(themeOption) => themeOption.value === interfaceTheme,
)?.description
}
</p>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Monitor className="h-4 w-4" />
Color Mode
</Label>
<Select
value={colorMode}
onValueChange={(value) =>
updateAppearance({
colorMode: value as typeof colorMode,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{colorModes.map((modeOption) => (
<SelectItem
key={modeOption.value}
value={modeOption.value}
>
{modeOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
{
colorModes.find(
(modeOption) => modeOption.value === colorMode,
)?.description
}
</p>
</div>
</div>
</section>
<section className="space-y-4 border-t pt-6">
<div>
<h3 className="text-sm font-medium">Typography</h3>
<p className="text-muted-foreground text-xs">
Body and heading fonts are separate so white-label installs
can feel native without losing hierarchy.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Type className="h-4 w-4" />
Body Font
</Label>
<Select
value={bodyFontPreference}
onValueChange={(value) =>
updateAppearance({
bodyFontPreference:
value as typeof bodyFontPreference,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{bodyFontPreferences.map((fontOption) => (
<SelectItem
key={fontOption.value}
value={fontOption.value}
>
{fontOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
{
bodyFontPreferences.find(
(fontOption) =>
fontOption.value === bodyFontPreference,
)?.description
}
</p>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Type className="h-4 w-4" />
Heading Font
</Label>
<Select
value={headingFontPreference}
onValueChange={(value) =>
updateAppearance({
headingFontPreference:
value as typeof headingFontPreference,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{headingFontPreferences.map((fontOption) => (
<SelectItem
key={fontOption.value}
value={fontOption.value}
>
{fontOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
{
headingFontPreferences.find(
(fontOption) =>
fontOption.value === headingFontPreference,
)?.description
}
</p>
</div>
</div>
</section>
<section className="space-y-4 border-t pt-6">
<div>
<h3 className="text-sm font-medium">Color</h3>
<p className="text-muted-foreground text-xs">
Accent controls primary actions, focus rings, and branded
highlights.
</p>
</div>
<div className="space-y-3">
<Label>Accent</Label>
<div className="grid gap-2 sm:grid-cols-3">
{colorThemes.map((themeOption) => (
<button
key={themeOption.value}
type="button"
onClick={() => selectAccent(themeOption.value)}
className={`border-border bg-background hover:bg-muted flex items-center gap-2 rounded-lg border p-2 text-left text-sm transition-colors ${
colorTheme === themeOption.value
? "border-primary bg-muted text-foreground"
: ""
}`}
>
<span
className="size-4 rounded-full border"
style={{ backgroundColor: themeOption.swatch }}
/>
{themeOption.label}
</button>
))}
<button
type="button"
onClick={() => selectAccent("custom")}
className={`border-border bg-background hover:bg-muted flex items-center gap-2 rounded-lg border p-2 text-left text-sm transition-colors ${
colorTheme === "custom"
? "border-primary bg-muted text-foreground"
: ""
}`}
>
<span
className="size-4 rounded-full border"
style={{
backgroundColor: customColor
? `hsl(${customColor})`
: "hsl(142.1 76.2% 36.3%)",
}}
/>
Custom
</button>
</div>
{colorTheme === "custom" && (
<div className="space-y-2">
<InputColor
label="Custom Accent"
value={hslChannelsToHex(customColorValue)}
onBlur={() => undefined}
onChange={(value) => {
if (isFullHexColor(value)) {
updateAppearanceDebounced({
colorTheme: "custom",
customColor: hexToHslChannels(value),
});
}
}}
className="mt-0"
/>
<Input
value={customColorValue}
onChange={(event) =>
updateAppearanceDebounced({
colorTheme: "custom",
customColor: event.target.value,
})
}
placeholder="142.1 76.2% 36.3%"
/>
</div>
)}
<p className="text-muted-foreground text-xs leading-snug">
Custom values use HSL channels, for example 142.1 76.2%
36.3%.
</p>
</div>
</section>
<section className="space-y-4 border-t pt-6">
<div>
<h3 className="text-sm font-medium">Layout</h3>
<p className="text-muted-foreground text-xs">
Control global rounding and whether navigation floats or
sits flush with the viewport.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Paintbrush className="h-4 w-4" />
Corner Radius
</Label>
<Select
value={radiusPreference}
onValueChange={(value) =>
updateAppearance({
radiusPreference: value as typeof radiusPreference,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{radiusPreferences.map((radiusOption) => (
<SelectItem
key={radiusOption.value}
value={radiusOption.value}
>
{radiusOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
{
radiusPreferences.find(
(radiusOption) =>
radiusOption.value === radiusPreference,
)?.description
}
</p>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
<PanelLeft className="h-4 w-4" />
Navigation Chrome
</Label>
<Select
value={sidebarStyle}
onValueChange={(value) =>
updateAppearance({
sidebarStyle: value as typeof sidebarStyle,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{sidebarStyles.map((styleOption) => (
<SelectItem
key={styleOption.value}
value={styleOption.value}
>
{styleOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-snug">
{
sidebarStyles.find(
(styleOption) => styleOption.value === sidebarStyle,
)?.description
}
</p>
</div>
</div>
</section>
{appearanceUpdating && (
<p className="text-muted-foreground text-xs">
Saving appearance...
</p>
)}
</CardContent>
)}
</Card> </Card>
{isAdmin && ( {isAdmin && (
@@ -1191,7 +704,12 @@ export function SettingsContent() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]"> <div
className={cn(
pageTabsGridClass,
"xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]",
)}
>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
<div className="space-y-2"> <div className="space-y-2">
@@ -1200,12 +718,13 @@ export function SettingsContent() {
PDF Template PDF Template
</Label> </Label>
<Select <Select
value={pdfTemplate} value={pdfSettings?.pdfTemplate ?? "classic"}
onValueChange={(value) => onValueChange={(value) =>
updateAppearance({ savePdfSettings({
pdfTemplate: value as typeof pdfTemplate, pdfTemplate: value as PdfTemplate,
}) })
} }
disabled={updatePdfSettingsMutation.isPending}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -1224,13 +743,11 @@ export function SettingsContent() {
<div className="space-y-2"> <div className="space-y-2">
<InputColor <InputColor
label="PDF Accent" label="PDF Accent"
value={pdfAccentColor} value={pdfSettings?.pdfAccentColor ?? "#111827"}
onBlur={() => undefined} onBlur={() => undefined}
onChange={(value) => { onChange={(value) => {
if (isFullHexColor(value)) { if (isFullHexColor(value)) {
updateAppearance({ savePdfSettings({ pdfAccentColor: value });
pdfAccentColor: value,
});
} }
}} }}
className="mt-0" className="mt-0"
@@ -1241,12 +758,11 @@ export function SettingsContent() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Footer Text</Label> <Label>Footer Text</Label>
<Input <Input
value={pdfFooterText} value={pdfSettings?.pdfFooterText ?? ""}
onChange={(event) => onChange={(event) =>
updateAppearanceDebounced({ savePdfSettings({ pdfFooterText: event.target.value })
pdfFooterText: event.target.value,
})
} }
disabled={updatePdfSettingsMutation.isPending}
/> />
</div> </div>
@@ -1259,10 +775,11 @@ export function SettingsContent() {
</p> </p>
</div> </div>
<Switch <Switch
checked={pdfShowLogo} checked={pdfSettings?.pdfShowLogo ?? true}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
updateAppearance({ pdfShowLogo: Boolean(checked) }) savePdfSettings({ pdfShowLogo: Boolean(checked) })
} }
disabled={updatePdfSettingsMutation.isPending}
aria-label="Toggle PDF logo" aria-label="Toggle PDF logo"
/> />
</div> </div>
@@ -1275,12 +792,13 @@ export function SettingsContent() {
</p> </p>
</div> </div>
<Switch <Switch
checked={pdfShowPageNumbers} checked={pdfSettings?.pdfShowPageNumbers ?? true}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
updateAppearance({ savePdfSettings({
pdfShowPageNumbers: Boolean(checked), pdfShowPageNumbers: Boolean(checked),
}) })
} }
disabled={updatePdfSettingsMutation.isPending}
aria-label="Toggle PDF page numbers" aria-label="Toggle PDF page numbers"
/> />
</div> </div>
@@ -1288,13 +806,14 @@ export function SettingsContent() {
</div> </div>
<PdfPreviewFrame <PdfPreviewFrame
businessName={brandName} businessName={brand.name}
settings={{ settings={{
pdfTemplate, pdfTemplate: pdfSettings?.pdfTemplate ?? "classic",
pdfAccentColor, pdfAccentColor: pdfSettings?.pdfAccentColor ?? "#111827",
pdfFooterText, pdfFooterText:
pdfShowLogo, pdfSettings?.pdfFooterText ?? "Professional Invoicing",
pdfShowPageNumbers, pdfShowLogo: pdfSettings?.pdfShowLogo ?? true,
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers ?? true,
}} }}
/> />
</div> </div>
@@ -1397,9 +916,9 @@ export function SettingsContent() {
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
<TabsContent value="data" className="space-y-8"> <PageTabsContent value="data">
{/* Data Overview */} {/* Data Overview */}
<Card className="form-section bg-card border-border border"> <Card className="form-section bg-card border-border border">
<CardHeader> <CardHeader>
@@ -1621,7 +1140,7 @@ export function SettingsContent() {
</Card> </Card>
{/* Delete Account (Danger Zone) */} {/* Delete Account (Danger Zone) */}
<Card className="border-destructive/50 bg-destructive/5 border"> <Card className="bg-card border-destructive/50 border">
<CardHeader> <CardHeader>
<CardTitle className="text-destructive flex items-center gap-2"> <CardTitle className="text-destructive flex items-center gap-2">
<AlertTriangle className="h-5 w-5" /> <AlertTriangle className="h-5 w-5" />
@@ -1672,11 +1191,11 @@ export function SettingsContent() {
</AlertDialog> </AlertDialog>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
<TabsContent value="api" className="space-y-8"> <PageTabsContent value="api">
<ApiAccessSettings /> <ApiAccessSettings />
</TabsContent> </PageTabsContent>
</Tabs> </PageTabs>
); );
} }
+5 -5
View File
@@ -1,16 +1,16 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { HydrateClient } from "~/trpc/server"; import { HydrateClient } from "~/trpc/server";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { DataTableSkeleton } from "~/components/data/data-table"; import { DataTableSkeleton } from "~/components/data/data-table";
import { SettingsContent } from "./_components/settings-content"; import { SettingsContent } from "./_components/settings-content";
export default async function SettingsPage() { export default async function SettingsPage() {
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<PageHeader <DashboardPageHeader
title="Settings" title="Settings"
description="Manage your account preferences and data" description="Manage your account preferences and data"
variant="gradient"
/> />
<HydrateClient> <HydrateClient>
@@ -18,6 +18,6 @@ export default async function SettingsPage() {
<SettingsContent /> <SettingsContent />
</Suspense> </Suspense>
</HydrateClient> </HydrateClient>
</div> </DashboardPage>
); );
} }
+3 -2
View File
@@ -1,5 +1,6 @@
import { HydrateClient, api } from "~/trpc/server"; import { HydrateClient, api } from "~/trpc/server";
import { DashboardPageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel"; import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
export default async function TimeClockPage({ export default async function TimeClockPage({
@@ -17,7 +18,7 @@ export default async function TimeClockPage({
} }
return ( return (
<div className="page-enter space-y-6"> <DashboardPage>
<DashboardPageHeader <DashboardPageHeader
title="Time clock" title="Time clock"
description="Track billable hours and save them directly to an invoice" description="Track billable hours and save them directly to an invoice"
@@ -28,6 +29,6 @@ export default async function TimeClockPage({
defaultInvoiceId={params.invoiceId} defaultInvoiceId={params.invoiceId}
/> />
</HydrateClient> </HydrateClient>
</div> </DashboardPage>
); );
} }
+5 -51
View File
@@ -4,14 +4,7 @@ import { type Metadata } from "next";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { Toaster } from "~/components/ui/sonner"; import { Toaster } from "~/components/ui/sonner";
import { import { brand } from "~/lib/branding";
brand,
defaultBodyFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
} from "~/lib/branding";
import { UmamiScript } from "~/components/analytics/umami-script"; import { UmamiScript } from "~/components/analytics/umami-script";
import { BrandBackground } from "~/components/layout/brand-background"; import { BrandBackground } from "~/components/layout/brand-background";
@@ -34,23 +27,6 @@ const playfair = localFont({
display: "swap", display: "swap",
}); });
const frutiger = localFont({
src: [
{
path: "../../public/fonts/frutiger/Frutiger.ttf",
weight: "400",
style: "normal",
},
{
path: "../../public/fonts/frutiger/Frutiger_bold.ttf",
weight: "700",
style: "normal",
},
],
variable: "--font-frutiger",
display: "swap",
});
const geistMono = localFont({ const geistMono = localFont({
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf", src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
variable: "--font-geist-mono", variable: "--font-geist-mono",
@@ -64,14 +40,8 @@ export default function RootLayout({
<html <html
suppressHydrationWarning suppressHydrationWarning
lang="en" lang="en"
data-interface-theme={defaultInterfaceTheme}
data-body-font={defaultBodyFontPreference}
data-heading-font={defaultHeadingFontPreference}
data-radius={defaultRadiusPreference}
data-sidebar-style={defaultSidebarStyle}
data-color-mode="system" data-color-mode="system"
data-color-theme="slate" className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
className={`${geistSans.variable} ${playfair.variable} ${frutiger.variable} ${geistMono.variable}`}
> >
<head> <head>
<script <script
@@ -79,27 +49,11 @@ export default function RootLayout({
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
try { try {
var defaults = {
interfaceTheme: "${defaultInterfaceTheme}",
bodyFontPreference: "${defaultBodyFontPreference}",
headingFontPreference: "${defaultHeadingFontPreference}",
radiusPreference: "${defaultRadiusPreference}",
sidebarStyle: "${defaultSidebarStyle}",
colorMode: "system",
colorTheme: "slate"
};
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}"); var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
var appearance = Object.assign(defaults, stored); var colorMode = stored.colorMode || "system";
var root = document.documentElement; var root = document.documentElement;
root.dataset.interfaceTheme = appearance.interfaceTheme; root.dataset.colorMode = colorMode;
root.dataset.bodyFont = appearance.bodyFontPreference; if (colorMode === "dark") root.classList.add("dark");
root.dataset.headingFont = appearance.headingFontPreference;
root.dataset.radius = appearance.radiusPreference;
root.dataset.sidebarStyle = appearance.sidebarStyle;
root.dataset.colorMode = appearance.colorMode;
root.dataset.colorTheme = appearance.colorTheme;
if (appearance.colorMode === "dark") root.classList.add("dark");
if (appearance.customColor) root.style.setProperty("--custom-primary", appearance.customColor);
} catch {} } catch {}
`, `,
}} }}
+6 -10
View File
@@ -2,7 +2,6 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { brand } from "~/lib/branding"; import { brand } from "~/lib/branding";
import { useAppearance } from "~/components/providers/appearance-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface LogoProps { interface LogoProps {
@@ -25,10 +24,7 @@ function splitLogoText(logoText: string) {
} }
export function Logo({ className, size = "md", animated = true }: LogoProps) { export function Logo({ className, size = "md", animated = true }: LogoProps) {
const appearance = useAppearance(); const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
const logoText = appearance.brandLogoText || brand.logoText;
const icon = appearance.brandIcon || brand.icon;
const [logoPrefix, logoSuffix] = splitLogoText(logoText);
const sizeClasses = { const sizeClasses = {
sm: "text-base", sm: "text-base",
md: "text-xl", md: "text-xl",
@@ -45,7 +41,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
sizeClasses={sizeClasses} sizeClasses={sizeClasses}
logoPrefix={logoPrefix} logoPrefix={logoPrefix}
logoSuffix={logoSuffix} logoSuffix={logoSuffix}
icon={icon} icon={brand.icon}
/> />
); );
} }
@@ -67,7 +63,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }} transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
className="text-primary font-bold tracking-tight" className="text-primary font-bold tracking-tight"
> >
{icon} {brand.icon}
</motion.span> </motion.span>
{size !== "icon" && ( {size !== "icon" && (
<> <>
@@ -75,8 +71,8 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }} transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
className="inline-block w-1" // Reduced from w-2 to w-1 (half space) className="inline-block w-1"
></motion.span> />
<motion.span <motion.span
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
@@ -125,7 +121,7 @@ function LogoContent({
<span className="text-primary font-bold tracking-tight">{icon}</span> <span className="text-primary font-bold tracking-tight">{icon}</span>
{size !== "icon" && ( {size !== "icon" && (
<> <>
<span className="inline-block w-1"></span> <span className="inline-block w-1" />
<span className="text-foreground font-bold tracking-tight"> <span className="text-foreground font-bold tracking-tight">
{logoPrefix} {logoPrefix}
</span> </span>
@@ -0,0 +1,25 @@
"use client";
import type { ReactElement } from "react";
import { ResponsiveContainer } from "recharts";
import { cn } from "~/lib/utils";
interface ResponsiveChartProps {
height?: number;
className?: string;
children: ReactElement;
}
export function ResponsiveChart({
height = 256,
className,
children,
}: ResponsiveChartProps) {
return (
<div className={cn("w-full min-w-0", className)}>
<ResponsiveContainer width="100%" height={height} minWidth={0}>
{children}
</ResponsiveContainer>
</div>
);
}
+14 -9
View File
@@ -20,7 +20,9 @@ import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AddressForm } from "~/components/forms/address-form"; import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@@ -408,7 +410,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
(mode === "edit" && isLoadingEmailConfig) (mode === "edit" && isLoadingEmailConfig)
) { ) {
return ( return (
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<Card> <Card>
<CardHeader> <CardHeader>
<Skeleton className="h-6 w-32" /> <Skeleton className="h-6 w-32" />
@@ -430,21 +432,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </DashboardPage>
); );
} }
return ( return (
<> <>
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<PageHeader <DashboardPageHeader
title={mode === "edit" ? "Edit Business" : "Add Business"} title={mode === "edit" ? "Edit Business" : "Add Business"}
description={ description={
mode === "edit" mode === "edit"
? "Update business information below" ? "Update business information below"
: "Enter business details below to add a new business." : "Enter business details below to add a new business."
} }
variant="gradient"
> >
<Button <Button
type="submit" type="submit"
@@ -469,9 +470,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</> </>
)} )}
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<form id="business-form" onSubmit={handleSubmit} className="space-y-6"> <form
id="business-form"
onSubmit={handleSubmit}
className={cn("flex flex-col", dashboardGapClass)}
>
{/* Main Form Container - styled like data table */} {/* Main Form Container - styled like data table */}
<div className="space-y-4"> <div className="space-y-4">
{/* Basic Information */} {/* Basic Information */}
@@ -902,7 +907,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</Card> </Card>
</div> </div>
</form> </form>
</div> </DashboardPage>
<FloatingActionBar <FloatingActionBar
leftContent={ leftContent={
+14 -9
View File
@@ -19,7 +19,9 @@ import { Label } from "~/components/ui/label";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { AddressForm } from "~/components/forms/address-form"; import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header"; import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { import {
@@ -237,7 +239,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
if (mode === "edit" && isLoadingClient) { if (mode === "edit" && isLoadingClient) {
return ( return (
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<Card> <Card>
<CardHeader> <CardHeader>
<Skeleton className="h-6 w-32" /> <Skeleton className="h-6 w-32" />
@@ -259,21 +261,20 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </DashboardPage>
); );
} }
return ( return (
<> <>
<div className="space-y-6 pb-32"> <DashboardPage className="pb-32">
<PageHeader <DashboardPageHeader
title={mode === "edit" ? "Edit Client" : "Add Client"} title={mode === "edit" ? "Edit Client" : "Add Client"}
description={ description={
mode === "edit" mode === "edit"
? "Update client information below" ? "Update client information below"
: "Enter client details below to add a new client." : "Enter client details below to add a new client."
} }
variant="gradient"
> >
<Button <Button
type="submit" type="submit"
@@ -298,9 +299,13 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</> </>
)} )}
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<form id="client-form" onSubmit={handleSubmit} className="space-y-6"> <form
id="client-form"
onSubmit={handleSubmit}
className={cn("flex flex-col", dashboardGapClass)}
>
{/* Main Form Container - styled like data table */} {/* Main Form Container - styled like data table */}
<div className="space-y-4"> <div className="space-y-4">
{/* Basic Information */} {/* Basic Information */}
@@ -508,7 +513,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</Card> </Card>
</div> </div>
</form> </form>
</div> </DashboardPage>
<FloatingActionBar <FloatingActionBar
leftContent={ leftContent={
+2 -4
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { getAppUrl } from "~/lib/app-url";
interface EmailPreviewProps { interface EmailPreviewProps {
subject: string; subject: string;
@@ -89,10 +90,7 @@ export function EmailPreview({
customMessage: customMessage, customMessage: customMessage,
userName: invoice.business?.name ?? "Your Business", userName: invoice.business?.name ?? "Your Business",
userEmail: fromEmail, userEmail: fromEmail,
baseUrl: baseUrl: getAppUrl(),
typeof window !== "undefined"
? window.location.origin
: "https://beenvoice.app",
}) })
: null; : null;
+55 -81
View File
@@ -6,7 +6,18 @@ import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "~/components/ui/button"; 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 { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import {
DashboardPage,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { cn } from "~/lib/utils";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -17,7 +28,6 @@ import {
import { DatePicker } from "~/components/ui/date-picker"; import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input"; import { NumberInput } from "~/components/ui/number-input";
import { PageHeader } from "~/components/layout/page-header";
import { InvoiceLineItems } from "./invoice-line-items"; import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view"; import { InvoiceCalendarView } from "./invoice-calendar-view";
import { EmailPreview } from "./email-preview"; import { EmailPreview } from "./email-preview";
@@ -62,19 +72,17 @@ interface InvoiceFormProps {
function InvoiceFormSkeleton() { function InvoiceFormSkeleton() {
return ( return (
<div className="space-y-6 pb-8"> <DashboardPage className="pb-8">
<PageHeader <DashboardPageHeader
title="Loading..." title="Loading..."
description="Loading invoice form" description="Loading invoice form"
variant="gradient"
/> />
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />{" "} <div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />
{/* Tabs Skeleton */} <div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="bg-muted h-[200px] animate-pulse rounded-xl" /> <div className="bg-muted h-[200px] animate-pulse rounded-xl" />
<div className="bg-muted h-[200px] animate-pulse rounded-xl" /> <div className="bg-muted h-[200px] animate-pulse rounded-xl" />
</div> </div>
</div> </DashboardPage>
); );
} }
@@ -462,8 +470,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return ( return (
<> <>
<div className="page-enter space-y-6 pb-8"> <DashboardPage className="pb-8">
<PageHeader <DashboardPageHeader
title={ title={
invoiceId !== "new" invoiceId !== "new"
? "Edit Invoice" ? "Edit Invoice"
@@ -476,7 +484,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
? "Set up a draft to clock time into later" ? "Set up a draft to clock time into later"
: "Manage your invoice" : "Manage your invoice"
} }
variant="gradient"
> >
{invoiceId !== "new" && ( {invoiceId !== "new" && (
<Button <Button
@@ -491,42 +498,28 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
{loading ? "Saving..." : "Save"} {loading ? "Saving..." : "Save"}
</Button> </Button>
</PageHeader> </DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"> <div
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}> className={cn(
{/* TAB SELECTOR: w-full, p-1, visible background */} dashboardGridClass,
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1 lg:grid-cols-3"> "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]",
<TabsTrigger )}
value="details" >
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm" <PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
> <PageTabsList>
Details <PageTabsTrigger value="details">Details</PageTabsTrigger>
</TabsTrigger> <PageTabsTrigger value="items">Items</PageTabsTrigger>
<TabsTrigger <PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
value="items" <PageTabsTrigger value="preview" className="lg:hidden">
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Items
</TabsTrigger>
<TabsTrigger
value="timesheet"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Timesheet
</TabsTrigger>
<TabsTrigger
value="preview"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm lg:hidden"
>
Preview Preview
</TabsTrigger> </PageTabsTrigger>
</TabsList> </PageTabsList>
{/* DETAILS TAB */} {/* DETAILS TAB */}
<TabsContent <PageTabsContent
value="details" value="details"
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2" className={cn(dashboardGridClass, "lg:grid-cols-2")}
> >
<Card className="h-full"> <Card className="h-full">
<CardHeader> <CardHeader>
@@ -770,13 +763,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/> />
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
{/* ITEMS TAB */} {/* ITEMS TAB */}
<TabsContent <PageTabsContent value="items">
value="items"
className="mt-6 focus-visible:outline-none"
>
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3"> <div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<Card className="bg-primary/5 border-primary/20"> <Card className="bg-primary/5 border-primary/20">
<CardContent className="flex items-center justify-between p-4"> <CardContent className="flex items-center justify-between p-4">
@@ -826,13 +816,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/> />
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
{/* TIMESHEET TAB */} {/* TIMESHEET TAB */}
<TabsContent <PageTabsContent value="timesheet">
value="timesheet"
className="mt-6 focus-visible:outline-none"
>
<Card className="min-h-[600px] w-full"> <Card className="min-h-[600px] w-full">
<CardHeader> <CardHeader>
<CardTitle className="flex gap-2"> <CardTitle className="flex gap-2">
@@ -850,37 +837,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/> />
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
<TabsContent <PageTabsContent value="preview">
value="preview" <PageTabs
className="mt-6 focus-visible:outline-none"
>
<Tabs
value={previewTab} value={previewTab}
onValueChange={setPreviewTab} onValueChange={setPreviewTab}
className="w-full" className="w-full"
> >
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1"> <PageTabsList>
<TabsTrigger <PageTabsTrigger value="pdf">PDF</PageTabsTrigger>
value="pdf" <PageTabsTrigger value="email">Email</PageTabsTrigger>
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm" </PageTabsList>
>
PDF
</TabsTrigger>
<TabsTrigger
value="email"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Email
</TabsTrigger>
</TabsList>
<TabsContent value="pdf" className="mt-6"> <PageTabsContent value="pdf">
<InvoicePdfPreviewPanel input={pdfPreviewInput} /> <InvoicePdfPreviewPanel input={pdfPreviewInput} />
</TabsContent> </PageTabsContent>
<TabsContent value="email" className="mt-6"> <PageTabsContent value="email">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex gap-2"> <CardTitle className="flex gap-2">
@@ -928,10 +902,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/> />
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </PageTabsContent>
</Tabs> </PageTabs>
</TabsContent> </PageTabsContent>
</Tabs> </PageTabs>
<aside className="hidden lg:block"> <aside className="hidden lg:block">
<div className="sticky top-4 space-y-4"> <div className="sticky top-4 space-y-4">
@@ -950,7 +924,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div> </div>
</aside> </aside>
</div> </div>
</div> </DashboardPage>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent> <DialogContent>
+59
View File
@@ -0,0 +1,59 @@
import { cn } from "~/lib/utils";
/** Vertical rhythm for dashboard pages — use with shell `gap-5`. */
export const dashboardGapClass = "gap-5 md:gap-6";
/** Standard grid gap for dashboard cards and sections. */
export const dashboardGridClass =
"grid gap-5 md:gap-6";
/** Summary stat cards (2-up mobile, 4-up desktop). */
export const dashboardStatGridClass =
"grid grid-cols-2 gap-4 sm:grid-cols-4";
export function DashboardPage({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"page-enter mx-auto flex w-full max-w-7xl flex-col",
dashboardGapClass,
className,
)}
>
{children}
</div>
);
}
export function DashboardGrid({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn(dashboardGridClass, className)}>{children}</div>
);
}
export function DashboardCardTitle({
children,
icon: Icon,
}: {
children: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
}) {
return (
<span className="flex items-center gap-2">
{Icon ? <Icon className="text-muted-foreground h-4 w-4" /> : null}
{children}
</span>
);
}
+21 -28
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { usePathname } from "next/navigation";
import { Sidebar } from "~/components/layout/sidebar"; import { Sidebar } from "~/components/layout/sidebar";
import { import {
SidebarProvider, SidebarProvider,
@@ -11,23 +12,29 @@ import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { useAppearance } from "~/components/providers/appearance-provider";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget"; import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
import { OnboardingGuard } from "~/components/layout/onboarding-guard";
function DashboardContent({ children }: { children: React.ReactNode }) { function DashboardContent({ children }: { children: React.ReactNode }) {
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance(); const pathname = usePathname();
const [isMobileOpen, setIsMobileOpen] = React.useState(false); const [isMobileOpen, setIsMobileOpen] = React.useState(false);
const isOnboarding = pathname === "/dashboard/onboarding";
return ( return (
<div className="bg-dashboard relative flex min-h-screen"> <div className="bg-dashboard relative flex min-h-screen">
{/* Desktop Sidebar */} {!isOnboarding && (
<div className="hidden md:block"> <div className="hidden md:block">
<Sidebar /> <Sidebar />
</div> </div>
)}
{/* Mobile Sidebar (Sheet) */} <div
<div className="dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden"> className={cn(
"dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden",
isOnboarding && "hidden",
)}
>
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}> <Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button <Button
@@ -40,9 +47,9 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<span className="sr-only">Toggle menu</span> <span className="sr-only">Toggle menu</span>
</Button> </Button>
</SheetTrigger> </SheetTrigger>
{/* Mobile Link / Logo */} <div className="ml-4 flex min-w-0 flex-1 items-center gap-2">
<div className="ml-4 flex items-center gap-2">
<Logo size="sm" /> <Logo size="sm" />
<ActiveTimerWidget compact />
</div> </div>
<SheetContent side="left" className="w-72 p-0"> <SheetContent side="left" className="w-72 p-0">
<div className="sr-only"> <div className="sr-only">
@@ -53,29 +60,15 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
</Sheet> </Sheet>
</div> </div>
{/* Main Content */}
<main <main
suppressHydrationWarning suppressHydrationWarning
className={cn( className={cn(
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out", "min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0",
"md:ml-0", !isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
sidebarStyle === "floating"
? isCollapsed
? "md:ml-24"
: "md:ml-[18rem]"
: isCollapsed
? "md:ml-16"
: "md:ml-64",
)} )}
> >
<div className="dashboard-content-shell p-4 pt-16 md:pt-4"> <div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
<div className="mb-4 md:hidden"> <OnboardingGuard>{children}</OnboardingGuard>
{/* Mobile Breadcrumbs could go here or be part of the page */}
</div>
<div className="mb-4">
<ActiveTimerWidget />
</div>
{children}
</div> </div>
</main> </main>
</div> </div>
@@ -0,0 +1,29 @@
"use client";
import { createContext, useContext } from "react";
interface DashboardUserContextValue {
isAdmin: boolean;
needsOnboarding: boolean;
}
const DashboardUserContext = createContext<DashboardUserContextValue>({
isAdmin: false,
needsOnboarding: false,
});
export function DashboardUserProvider({
isAdmin,
needsOnboarding,
children,
}: DashboardUserContextValue & { children: React.ReactNode }) {
return (
<DashboardUserContext.Provider value={{ isAdmin, needsOnboarding }}>
{children}
</DashboardUserContext.Provider>
);
}
export function useDashboardUser() {
return useContext(DashboardUserContext);
}
+1 -12
View File
@@ -3,15 +3,11 @@
import React from "react"; import React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import { useAppearance } from "~/components/providers/appearance-provider";
import { useSidebar } from "~/components/layout/sidebar-provider"; import { useSidebar } from "~/components/layout/sidebar-provider";
interface FloatingActionBarProps { interface FloatingActionBarProps {
/** Content to display on the left side */
leftContent?: React.ReactNode; leftContent?: React.ReactNode;
/** Action buttons to display on the right */
children: React.ReactNode; children: React.ReactNode;
/** Additional className for styling */
className?: string; className?: string;
} }
@@ -21,19 +17,12 @@ export function FloatingActionBar({
className, className,
}: FloatingActionBarProps) { }: FloatingActionBarProps) {
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
return ( return (
<div <div
className={cn( className={cn(
"pb-safe-area-inset-bottom fixed right-0 bottom-4 left-0 z-50 transition-all duration-300 ease-in-out", "pb-safe-area-inset-bottom fixed right-0 bottom-4 left-0 z-50 transition-all duration-300 ease-in-out",
sidebarStyle === "floating" isCollapsed ? "md:left-16" : "md:left-64",
? isCollapsed
? "md:left-24"
: "md:left-[18rem]"
: isCollapsed
? "md:left-16"
: "md:left-64",
"animate-slide-in-bottom", "animate-slide-in-bottom",
className, className,
)} )}
@@ -0,0 +1,24 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useDashboardUser } from "./dashboard-user-context";
export function OnboardingGuard({ children }: { children: React.ReactNode }) {
const { needsOnboarding } = useDashboardUser();
const pathname = usePathname();
const router = useRouter();
const onOnboardingPage = pathname === "/dashboard/onboarding";
useEffect(() => {
if (needsOnboarding && !onOnboardingPage) {
router.replace("/dashboard/onboarding");
}
}, [needsOnboarding, onOnboardingPage, router]);
if (needsOnboarding && !onOnboardingPage) {
return null;
}
return children;
}
+5 -3
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs"; import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
import { cn } from "~/lib/utils";
interface PageHeaderProps { interface PageHeaderProps {
title: string; title: string;
@@ -40,7 +41,7 @@ export function PageHeader({
}; };
return ( return (
<div className={`animate-fade-in-down mb-6 ${className}`}> <div className={cn("animate-fade-in-down", className)}>
{variant === "large-gradient" || variant === "gradient" ? ( {variant === "large-gradient" || variant === "gradient" ? (
<div className="platform-header-surface bg-card text-card-foreground relative overflow-hidden rounded-xl border shadow-sm"> <div className="platform-header-surface bg-card text-card-foreground relative overflow-hidden rounded-xl border shadow-sm">
<div className="platform-header-gradient from-primary/5 pointer-events-none absolute inset-0 bg-gradient-to-br via-transparent to-transparent" /> <div className="platform-header-gradient from-primary/5 pointer-events-none absolute inset-0 bg-gradient-to-br via-transparent to-transparent" />
@@ -104,8 +105,9 @@ export function DashboardPageHeader({
<PageHeader <PageHeader
title={title} title={title}
description={description} description={description}
variant="large-gradient" variant="gradient"
className={className} className={cn("mb-0", className)}
titleClassName="font-heading text-2xl font-semibold tracking-tight sm:text-3xl"
> >
{children} {children}
</PageHeader> </PageHeader>
+75
View File
@@ -0,0 +1,75 @@
"use client";
import * as React from "react";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "~/components/ui/tabs";
import { dashboardGapClass, dashboardGridClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
/** Vertical rhythm inside tab panels — matches dashboard page sections. */
export const pageTabsPanelClass = dashboardGapClass;
/** Grid for stacked cards inside a tab panel. */
export const pageTabsGridClass = dashboardGridClass;
type PageTabsProps = React.ComponentPropsWithoutRef<typeof Tabs>;
export function PageTabs({ className, ...props }: PageTabsProps) {
return (
<Tabs
className={cn("flex flex-col", pageTabsPanelClass, className)}
{...props}
/>
);
}
type PageTabsListProps = React.ComponentPropsWithoutRef<typeof TabsList>;
export function PageTabsList({ className, ...props }: PageTabsListProps) {
return (
<div className="-mx-1 overflow-x-auto px-1 pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<TabsList
className={cn(
"bg-muted/50 border-border/60 inline-flex h-10 w-max min-w-full gap-0.5 rounded-xl border p-1 sm:min-w-0",
className,
)}
{...props}
/>
</div>
);
}
export function PageTabsTrigger({
className,
...props
}: React.ComponentPropsWithoutRef<typeof TabsTrigger>) {
return (
<TabsTrigger
className={cn(
"data-[state=active]:bg-background h-8 rounded-lg px-3.5 text-sm data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
);
}
export function PageTabsContent({
className,
...props
}: React.ComponentPropsWithoutRef<typeof TabsContent>) {
return (
<TabsContent
className={cn(
"mt-0 flex flex-col focus-visible:ring-0 focus-visible:outline-none",
pageTabsPanelClass,
className,
)}
{...props}
/>
);
}
+9 -8
View File
@@ -6,7 +6,7 @@ import { authClient } from "~/lib/auth-client";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { navigationConfig, isNavLinkActive } from "~/lib/navigation"; import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { useSidebar } from "./sidebar-provider"; import { useSidebar } from "./sidebar-provider";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Logo } from "~/components/branding/logo"; import { Logo } from "~/components/branding/logo";
@@ -26,8 +26,9 @@ import {
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import { getGravatarUrl } from "~/lib/gravatar"; import { getGravatarUrl } from "~/lib/gravatar";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { useAppearance } from "~/components/providers/appearance-provider";
import { useAuthSession } from "~/hooks/use-auth-session"; import { useAuthSession } from "~/hooks/use-auth-session";
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
interface SidebarProps { interface SidebarProps {
mobile?: boolean; mobile?: boolean;
@@ -37,8 +38,9 @@ interface SidebarProps {
export function Sidebar({ mobile, onClose }: SidebarProps) { export function Sidebar({ mobile, onClose }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const { data: session, isPending } = useAuthSession(); const { data: session, isPending } = useAuthSession();
const { isAdmin } = useDashboardUser();
const { isCollapsed, toggleCollapse } = useSidebar(); const { isCollapsed, toggleCollapse } = useSidebar();
const { sidebarStyle } = useAppearance(); const navSections = getNavigationForUser(isAdmin);
// If mobile, always expanded // If mobile, always expanded
const collapsed = mobile ? false : isCollapsed; const collapsed = mobile ? false : isCollapsed;
@@ -72,7 +74,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
collapsed && "items-center", collapsed && "items-center",
)} )}
> >
{navigationConfig.map((section) => ( {navSections.map((section) => (
<div key={section.title}> <div key={section.title}>
{!collapsed && ( {!collapsed && (
<div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase"> <div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase">
@@ -163,6 +165,8 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
</div> </div>
)} )}
<ActiveTimerWidget collapsed={collapsed} />
<div <div
className={cn( className={cn(
"border-border/50 border-t pt-4", "border-border/50 border-t pt-4",
@@ -265,10 +269,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
return ( return (
<aside <aside
className={cn( className={cn(
"fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex", "border-border bg-background fixed top-0 bottom-0 left-0 z-30 hidden flex-col rounded-none border-r shadow-none transition-all duration-300 ease-in-out md:flex",
sidebarStyle === "floating"
? "border-border/50 bg-background/80 top-4 bottom-4 left-4 rounded-3xl border shadow-xl backdrop-blur-xl"
: "border-border bg-background top-0 bottom-0 left-0 rounded-none border-r shadow-none",
isCollapsed ? "w-16" : "w-64", isCollapsed ? "w-16" : "w-64",
)} )}
> >
+6 -3
View File
@@ -9,8 +9,11 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { BrowserFrame } from "~/components/marketing/browser-frame"; import { BrowserFrame } from "~/components/marketing/browser-frame";
import { getAppHost } from "~/lib/app-url";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
const appHost = getAppHost();
function MockSidebar({ active }: { active: "dashboard" | "invoices" | "time" }) { function MockSidebar({ active }: { active: "dashboard" | "invoices" | "time" }) {
const items = [ const items = [
{ id: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard }, { id: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
@@ -87,7 +90,7 @@ export function InvoicesScreenshot({ className }: { className?: string }) {
]; ];
return ( return (
<BrowserFrame className={className} url="beenvoice.app/dashboard/invoices"> <BrowserFrame className={className} url={`${appHost}/dashboard/invoices`}>
<div className="flex min-h-[280px] sm:min-h-[320px]"> <div className="flex min-h-[280px] sm:min-h-[320px]">
<MockSidebar active="invoices" /> <MockSidebar active="invoices" />
<div className="min-w-0 flex-1 p-4 sm:p-5"> <div className="min-w-0 flex-1 p-4 sm:p-5">
@@ -134,7 +137,7 @@ export function InvoicesScreenshot({ className }: { className?: string }) {
export function TimeClockScreenshot({ className }: { className?: string }) { export function TimeClockScreenshot({ className }: { className?: string }) {
return ( return (
<BrowserFrame className={className} url="beenvoice.app/dashboard/time-clock"> <BrowserFrame className={className} url={`${appHost}/dashboard/time-clock`}>
<div className="flex min-h-[260px] sm:min-h-[300px]"> <div className="flex min-h-[260px] sm:min-h-[300px]">
<MockSidebar active="time" /> <MockSidebar active="time" />
<div className="min-w-0 flex-1 p-4 sm:p-5"> <div className="min-w-0 flex-1 p-4 sm:p-5">
@@ -204,7 +207,7 @@ export function TimeClockScreenshot({ className }: { className?: string }) {
export function DashboardScreenshot({ className }: { className?: string }) { export function DashboardScreenshot({ className }: { className?: string }) {
return ( return (
<BrowserFrame className={className} url="beenvoice.app/dashboard"> <BrowserFrame className={className} url={`${appHost}/dashboard`}>
<div className="flex min-h-[260px] sm:min-h-[300px]"> <div className="flex min-h-[260px] sm:min-h-[300px]">
<MockSidebar active="dashboard" /> <MockSidebar active="dashboard" />
<div className="min-w-0 flex-1 p-4 sm:p-5"> <div className="min-w-0 flex-1 p-4 sm:p-5">
+2 -1
View File
@@ -1,9 +1,10 @@
import { getAppHost } from "~/lib/app-url";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
export function BrowserFrame({ export function BrowserFrame({
children, children,
className, className,
url = "beenvoice.app/dashboard", url = `${getAppHost()}/dashboard`,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
@@ -6,7 +6,8 @@ import { usePathname } from "next/navigation";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton"; import { Skeleton } from "~/components/ui/skeleton";
import { useAuthSession } from "~/hooks/use-auth-session"; import { useAuthSession } from "~/hooks/use-auth-session";
import { navigationConfig, isNavLinkActive } from "~/lib/navigation"; import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
interface SidebarTriggerProps { interface SidebarTriggerProps {
isOpen: boolean; isOpen: boolean;
@@ -16,6 +17,8 @@ interface SidebarTriggerProps {
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) { export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
const pathname = usePathname(); const pathname = usePathname();
const { isPending } = useAuthSession(); const { isPending } = useAuthSession();
const { isAdmin } = useDashboardUser();
const navSections = getNavigationForUser(isAdmin);
return ( return (
<> <>
@@ -34,7 +37,7 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
<div className="bg-background border-border absolute top-full right-0 left-0 z-40 mt-1 border-t"> <div className="bg-background border-border absolute top-full right-0 left-0 z-40 mt-1 border-t">
{/* Navigation content */} {/* Navigation content */}
<nav className="flex flex-col p-4"> <nav className="flex flex-col p-4">
{navigationConfig.map((section, sectionIndex) => ( {navSections.map((section, sectionIndex) => (
<div <div
key={section.title} key={section.title}
className={sectionIndex > 0 ? "mt-4" : ""} className={sectionIndex > 0 ? "mt-4" : ""}
@@ -177,7 +177,6 @@ export function AnimationPreferencesProviderSynced({
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier; serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) { if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect
performUpdate( performUpdate(
{ {
prefersReducedMotion: serverPrefs.prefersReducedMotion, prefersReducedMotion: serverPrefs.prefersReducedMotion,
@@ -187,12 +186,9 @@ export function AnimationPreferencesProviderSynced({
); );
} }
serverHydratedRef.current = true; serverHydratedRef.current = true;
}, [ // One-time hydration from server after local storage is read.
serverPrefs, // eslint-disable-next-line react-hooks/exhaustive-deps
performUpdate, }, [serverPrefs]);
prefersReducedMotion,
animationSpeedMultiplier,
]);
const updatePreferences = useCallback< const updatePreferences = useCallback<
AnimationPreferencesContextValue["updatePreferences"] AnimationPreferencesContextValue["updatePreferences"]
@@ -1,232 +1,88 @@
"use client"; "use client";
import { import { useCallback, useEffect, useMemo, useRef, useState } from "react";
useCallback, import { defaultColorMode, type ColorMode } from "~/lib/appearance";
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 { api } from "~/trpc/react";
import { import {
AppearanceContext, AppearanceContext,
applyAppearance, applyColorMode,
defaultAppearance, defaultAppearance,
readStoredAppearance, readStoredColorMode,
writeStoredAppearance, writeStoredColorMode,
type AppearanceContextValue, type AppearanceContextValue,
type AppearancePatch, type AppearancePatch,
type AppearancePreferences,
} from "~/components/providers/appearance-provider"; } from "~/components/providers/appearance-provider";
type ServerAppearance = { /** Dashboard appearance provider with per-user color mode sync. */
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({ export function AppearanceProviderSynced({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [appearance, setAppearance] = const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
useState<AppearancePreferences>(defaultAppearance); const serverHydratedRef = useRef(false);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
const utils = api.useUtils(); const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({ const updateMutation = api.settings.updateColorMode.useMutation({
onSuccess: async () => {
await utils.settings.getTheme.invalidate();
},
onError: () => { onError: () => {
const cachedAppearance = utils.settings.getTheme.getData(); const cached = utils.settings.getColorMode.getData();
const fallback = cachedAppearance const fallback = cached?.colorMode ?? defaultColorMode;
? { setColorMode(fallback);
...defaultAppearance, applyColorMode(fallback);
...getServerAppearancePatch(cachedAppearance), writeStoredColorMode(fallback);
}
: defaultAppearance;
setAppearance(fallback);
applyAppearance(fallback);
writeStoredAppearance(fallback);
}, },
}); });
const persistAppearance = useCallback( const { data: serverColorMode } = api.settings.getColorMode.useQuery(
(patch: AppearancePatch) => { undefined,
if ( {
patch.customColor !== undefined && retry: false,
!isHslChannels(patch.customColor) refetchOnWindowFocus: false,
) { staleTime: 60_000,
return; },
} );
updateMutation.mutate({ useEffect(() => {
interfaceTheme: patch.interfaceTheme, const stored = readStoredColorMode();
bodyFontPreference: patch.bodyFontPreference, if (stored) {
headingFontPreference: patch.headingFontPreference, // eslint-disable-next-line react-hooks/set-state-in-effect
radiusPreference: patch.radiusPreference, setColorMode(stored);
sidebarStyle: patch.sidebarStyle, }
theme: patch.colorMode, }, []);
colorTheme: patch.colorTheme,
customColor: patch.customColor, useEffect(() => {
brandName: patch.brandName, if (!serverColorMode?.colorMode) return;
brandTagline: patch.brandTagline, if (serverHydratedRef.current) return;
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon, // eslint-disable-next-line react-hooks/set-state-in-effect
pdfTemplate: patch.pdfTemplate, setColorMode(serverColorMode.colorMode);
pdfAccentColor: patch.pdfAccentColor, serverHydratedRef.current = true;
pdfFooterText: patch.pdfFooterText, }, [serverColorMode?.colorMode]);
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers, useEffect(() => {
}); applyColorMode(colorMode);
writeStoredColorMode(colorMode);
}, [colorMode]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
if (!patch.colorMode) return;
setColorMode(patch.colorMode);
applyColorMode(patch.colorMode);
writeStoredColorMode(patch.colorMode);
updateMutation.mutate({ colorMode: patch.colorMode });
}, },
[updateMutation], [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>( const value = useMemo<AppearanceContextValue>(
() => ({ () => ({
...appearance, ...defaultAppearance,
colorMode,
updateAppearance, updateAppearance,
updateAppearanceDebounced,
isUpdating: updateMutation.isPending, isUpdating: updateMutation.isPending,
}), }),
[ [colorMode, updateAppearance, updateMutation.isPending],
appearance,
updateAppearance,
updateAppearanceDebounced,
updateMutation.isPending,
],
); );
return ( return (
+26 -166
View File
@@ -8,181 +8,53 @@ import {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { import { defaultColorMode, isColorMode, type ColorMode } from "~/lib/appearance";
fallbackAppearance,
isColorMode,
isColorTheme,
isFontPreference,
isHslChannels,
isInterfaceTheme,
isPdfTemplate,
isRadiusPreference,
isSidebarStyle,
type PdfTemplate,
} from "~/lib/appearance";
import {
defaultBodyFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
brand as defaultBrand,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
export type AppearancePreferences = { export type AppearancePreferences = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
colorMode: ColorMode; colorMode: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
}; };
export type AppearancePatch = Partial<AppearancePreferences>; export type AppearancePatch = Partial<AppearancePreferences>;
export type AppearanceContextValue = AppearancePreferences & { export type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void; updateAppearance: (patch: AppearancePatch) => void;
updateAppearanceDebounced: (patch: AppearancePatch) => void;
isUpdating: boolean; isUpdating: boolean;
}; };
export const STORAGE_KEY = "bv.appearance"; export const STORAGE_KEY = "bv.appearance";
export const defaultAppearance: AppearancePreferences = { export const defaultAppearance: AppearancePreferences = {
interfaceTheme: defaultInterfaceTheme, colorMode: defaultColorMode,
bodyFontPreference: defaultBodyFontPreference,
headingFontPreference: defaultHeadingFontPreference,
radiusPreference: defaultRadiusPreference,
sidebarStyle: defaultSidebarStyle,
colorMode: fallbackAppearance.colorMode,
colorTheme: fallbackAppearance.colorTheme,
brandName: defaultBrand.name,
brandTagline: defaultBrand.tagline,
brandLogoText: defaultBrand.logoText,
brandIcon: defaultBrand.icon,
pdfTemplate: fallbackAppearance.pdfTemplate,
pdfAccentColor: fallbackAppearance.pdfAccentColor,
pdfFooterText: fallbackAppearance.pdfFooterText,
pdfShowLogo: fallbackAppearance.pdfShowLogo,
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
}; };
export const AppearanceContext = export const AppearanceContext =
createContext<AppearanceContextValue | null>(null); createContext<AppearanceContextValue | null>(null);
export function readStoredAppearance(): Partial<AppearancePreferences> | null { export function readStoredColorMode(): ColorMode | null {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>; const parsed = JSON.parse(raw) as { colorMode?: unknown };
return { return isColorMode(parsed.colorMode) ? parsed.colorMode : null;
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
? parsed.interfaceTheme
: undefined,
bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
? parsed.bodyFontPreference
: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
headingFontPreference: isFontPreference(parsed.headingFontPreference)
? parsed.headingFontPreference
: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
radiusPreference: isRadiusPreference(parsed.radiusPreference)
? parsed.radiusPreference
: undefined,
sidebarStyle: isSidebarStyle(parsed.sidebarStyle)
? parsed.sidebarStyle
: undefined,
colorMode: isColorMode(parsed.colorMode) ? parsed.colorMode : undefined,
colorTheme: isColorTheme(parsed.colorTheme)
? parsed.colorTheme
: undefined,
customColor: isHslChannels(parsed.customColor)
? parsed.customColor
: undefined,
brandName:
typeof parsed.brandName === "string" ? parsed.brandName : undefined,
brandTagline:
typeof parsed.brandTagline === "string"
? parsed.brandTagline
: undefined,
brandLogoText:
typeof parsed.brandLogoText === "string"
? parsed.brandLogoText
: undefined,
brandIcon:
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
pdfTemplate: isPdfTemplate(parsed.pdfTemplate)
? parsed.pdfTemplate
: undefined,
pdfAccentColor:
typeof parsed.pdfAccentColor === "string"
? parsed.pdfAccentColor
: undefined,
pdfFooterText:
typeof parsed.pdfFooterText === "string"
? parsed.pdfFooterText
: undefined,
pdfShowLogo:
typeof parsed.pdfShowLogo === "boolean"
? parsed.pdfShowLogo
: undefined,
pdfShowPageNumbers:
typeof parsed.pdfShowPageNumbers === "boolean"
? parsed.pdfShowPageNumbers
: undefined,
};
} catch { } catch {
return null; return null;
} }
} }
export function writeStoredAppearance(prefs: AppearancePreferences) { export function writeStoredColorMode(colorMode: ColorMode) {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); localStorage.setItem(STORAGE_KEY, JSON.stringify({ colorMode }));
} catch { } catch {
// Storage can be unavailable in private browsing or locked-down contexts. // Storage can be unavailable in private browsing or locked-down contexts.
} }
} }
export function applyAppearance(prefs: AppearancePreferences) { export function applyColorMode(colorMode: ColorMode) {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const root = document.documentElement; const root = document.documentElement;
root.dataset.interfaceTheme = prefs.interfaceTheme; root.dataset.colorMode = colorMode;
root.dataset.bodyFont = prefs.bodyFontPreference; root.classList.toggle("dark", colorMode === "dark");
root.dataset.headingFont = prefs.headingFontPreference;
root.dataset.radius = prefs.radiusPreference;
root.dataset.sidebarStyle = prefs.sidebarStyle;
root.dataset.colorMode = prefs.colorMode;
root.dataset.colorTheme = prefs.colorTheme;
root.classList.toggle("dark", prefs.colorMode === "dark");
if (prefs.customColor) {
root.style.setProperty("--custom-primary", prefs.customColor);
} else {
root.style.removeProperty("--custom-primary");
}
} }
/** Local-only appearance provider for marketing and auth pages (no tRPC). */ /** Local-only appearance provider for marketing and auth pages (no tRPC). */
@@ -191,48 +63,36 @@ export function AppearanceProvider({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [appearance, setAppearance] = const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
useState<AppearancePreferences>(defaultAppearance);
useEffect(() => { useEffect(() => {
const storedAppearance = readStoredAppearance(); const stored = readStoredColorMode();
if (!storedAppearance) return; if (stored) {
// eslint-disable-next-line react-hooks/set-state-in-effect
// eslint-disable-next-line react-hooks/set-state-in-effect setColorMode(stored);
setAppearance((prev) => ({ ...prev, ...storedAppearance })); }
}, []); }, []);
useEffect(() => { useEffect(() => {
applyAppearance(appearance); applyColorMode(colorMode);
writeStoredAppearance(appearance); writeStoredColorMode(colorMode);
}, [appearance]); }, [colorMode]);
const updateAppearance = useCallback((patch: AppearancePatch) => { const updateAppearance = useCallback((patch: AppearancePatch) => {
setAppearance((prev) => { if (patch.colorMode) {
const next = { ...prev, ...patch }; setColorMode(patch.colorMode);
applyAppearance(next); applyColorMode(patch.colorMode);
writeStoredAppearance(next); writeStoredColorMode(patch.colorMode);
return next; }
});
}, []);
const updateAppearanceDebounced = useCallback((patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
}, []); }, []);
const value = useMemo<AppearanceContextValue>( const value = useMemo<AppearanceContextValue>(
() => ({ () => ({
...appearance, colorMode,
updateAppearance, updateAppearance,
updateAppearanceDebounced,
isUpdating: false, isUpdating: false,
}), }),
[appearance, updateAppearance, updateAppearanceDebounced], [colorMode, updateAppearance],
); );
return ( return (
+2 -2
View File
@@ -11,7 +11,7 @@ const Tabs = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<TabsPrimitive.Root <TabsPrimitive.Root
ref={ref} ref={ref}
className={cn("flex flex-col gap-1", className)} className={cn("flex flex-col gap-2", className)}
{...props} {...props}
/> />
)); ));
@@ -54,7 +54,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content <TabsPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none", "ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
className, className,
)} )}
{...props} {...props}
-34
View File
@@ -56,31 +56,6 @@ export const env = createEnv({
NEXT_PUBLIC_BRAND_TAGLINE: z.string().optional(), NEXT_PUBLIC_BRAND_TAGLINE: z.string().optional(),
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(), NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
NEXT_PUBLIC_BRAND_ICON: z.string().optional(), NEXT_PUBLIC_BRAND_ICON: z.string().optional(),
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME: z
.enum([
"beenvoice",
"frutiger",
"frutiger-aero",
"shadcn",
"minimal",
"editorial",
])
.optional(),
NEXT_PUBLIC_DEFAULT_FONT: z
.enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_BODY_FONT: z
.enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_HEADING_FONT: z
.enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_RADIUS: z
.enum(["none", "sm", "md", "lg", "xl"])
.optional(),
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z
.enum(["floating", "docked"])
.optional(),
}, },
/** /**
@@ -109,15 +84,6 @@ export const env = createEnv({
NEXT_PUBLIC_BRAND_TAGLINE: process.env.NEXT_PUBLIC_BRAND_TAGLINE, NEXT_PUBLIC_BRAND_TAGLINE: process.env.NEXT_PUBLIC_BRAND_TAGLINE,
NEXT_PUBLIC_BRAND_LOGO_TEXT: process.env.NEXT_PUBLIC_BRAND_LOGO_TEXT, NEXT_PUBLIC_BRAND_LOGO_TEXT: process.env.NEXT_PUBLIC_BRAND_LOGO_TEXT,
NEXT_PUBLIC_BRAND_ICON: process.env.NEXT_PUBLIC_BRAND_ICON, NEXT_PUBLIC_BRAND_ICON: process.env.NEXT_PUBLIC_BRAND_ICON,
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME:
process.env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME,
NEXT_PUBLIC_DEFAULT_FONT: process.env.NEXT_PUBLIC_DEFAULT_FONT,
NEXT_PUBLIC_DEFAULT_BODY_FONT: process.env.NEXT_PUBLIC_DEFAULT_BODY_FONT,
NEXT_PUBLIC_DEFAULT_HEADING_FONT:
process.env.NEXT_PUBLIC_DEFAULT_HEADING_FONT,
NEXT_PUBLIC_DEFAULT_RADIUS: process.env.NEXT_PUBLIC_DEFAULT_RADIUS,
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE:
process.env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE,
}, },
/** /**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
+6
View File
@@ -0,0 +1,6 @@
export const APP_EMAIL_DOMAIN = "beenvoice.app";
export const PRIVACY_EMAIL = `privacy@${APP_EMAIL_DOMAIN}`;
export const LEGAL_EMAIL = `legal@${APP_EMAIL_DOMAIN}`;
export const SUPPORT_EMAIL = `support@${APP_EMAIL_DOMAIN}`;
export const NOREPLY_EMAIL = `noreply@${APP_EMAIL_DOMAIN}`;
+20
View File
@@ -0,0 +1,20 @@
/** Public app origin (no trailing slash). */
export function getAppUrl(): string {
if (typeof window !== "undefined") {
return window.location.origin.replace(/\/$/, "");
}
const fromEnv = process.env.NEXT_PUBLIC_APP_URL?.trim();
if (fromEnv) return fromEnv.replace(/\/$/, "");
return `http://localhost:${process.env.PORT ?? 3000}`;
}
/** Hostname for display (e.g. marketing browser chrome). */
export function getAppHost(): string {
try {
return new URL(getAppUrl()).host;
} catch {
return "beenvoice.app";
}
}
+4 -102
View File
@@ -1,126 +1,28 @@
import { z } from "zod"; import { z } from "zod";
export const interfaceThemeValues = [
"beenvoice",
"frutiger",
"frutiger-aero",
"shadcn",
"minimal",
"editorial",
] as const;
export const fontPreferenceValues = [
"brand",
"frutiger",
"platform",
"inter",
"serif",
] as const;
export const radiusPreferenceValues = ["none", "sm", "md", "lg", "xl"] as const;
export const sidebarStyleValues = ["floating", "docked"] as const;
export const colorModeValues = ["light", "dark", "system"] as const; export const colorModeValues = ["light", "dark", "system"] as const;
export const colorThemeValues = [
"slate",
"blue",
"green",
"rose",
"orange",
"custom",
] as const;
export const pdfTemplateValues = ["classic", "minimal"] as const; export const pdfTemplateValues = ["classic", "minimal"] as const;
export const interfaceThemeSchema = z.enum(interfaceThemeValues);
export const fontPreferenceSchema = z.enum(fontPreferenceValues);
export const radiusPreferenceSchema = z.enum(radiusPreferenceValues);
export const sidebarStyleSchema = z.enum(sidebarStyleValues);
export const colorModeSchema = z.enum(colorModeValues); export const colorModeSchema = z.enum(colorModeValues);
export const colorThemeSchema = z.enum(colorThemeValues);
export const pdfTemplateSchema = z.enum(pdfTemplateValues); export const pdfTemplateSchema = z.enum(pdfTemplateValues);
export const hslChannelsSchema = z
.string()
.trim()
.regex(
/^(?:360(?:\.0)?|3[0-5]\d(?:\.\d)?|[12]?\d?\d(?:\.\d)?)\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%$/,
"Use HSL channels like 142.1 76.2% 36.3%",
);
export type InterfaceTheme = z.infer<typeof interfaceThemeSchema>;
export type FontPreference = z.infer<typeof fontPreferenceSchema>;
export type RadiusPreference = z.infer<typeof radiusPreferenceSchema>;
export type SidebarStyle = z.infer<typeof sidebarStyleSchema>;
export type ColorMode = z.infer<typeof colorModeSchema>; export type ColorMode = z.infer<typeof colorModeSchema>;
export type ColorTheme = z.infer<typeof colorThemeSchema>;
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>; export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
export const fallbackAppearance = { export const defaultColorMode: ColorMode = "system";
interfaceTheme: "beenvoice",
fontPreference: "brand", export const defaultPdfSettings = {
bodyFontPreference: "brand", pdfTemplate: "classic" as PdfTemplate,
headingFontPreference: "brand",
radiusPreference: "xl",
sidebarStyle: "floating",
colorMode: "system",
colorTheme: "slate",
customColor: undefined,
brandName: "beenvoice",
brandTagline:
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: "beenvoice",
brandIcon: "$",
pdfTemplate: "classic",
pdfAccentColor: "#111827", pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing", pdfFooterText: "Professional Invoicing",
pdfShowLogo: true, pdfShowLogo: true,
pdfShowPageNumbers: true, pdfShowPageNumbers: true,
} satisfies {
interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
colorMode: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
}; };
export function isInterfaceTheme(value: unknown): value is InterfaceTheme {
return interfaceThemeSchema.safeParse(value).success;
}
export function isFontPreference(value: unknown): value is FontPreference {
return fontPreferenceSchema.safeParse(value).success;
}
export function isColorMode(value: unknown): value is ColorMode { export function isColorMode(value: unknown): value is ColorMode {
return colorModeSchema.safeParse(value).success; return colorModeSchema.safeParse(value).success;
} }
export function isColorTheme(value: unknown): value is ColorTheme {
return colorThemeSchema.safeParse(value).success;
}
export function isRadiusPreference(value: unknown): value is RadiusPreference {
return radiusPreferenceSchema.safeParse(value).success;
}
export function isSidebarStyle(value: unknown): value is SidebarStyle {
return sidebarStyleSchema.safeParse(value).success;
}
export function isPdfTemplate(value: unknown): value is PdfTemplate { export function isPdfTemplate(value: unknown): value is PdfTemplate {
return pdfTemplateSchema.safeParse(value).success; return pdfTemplateSchema.safeParse(value).success;
} }
export function isHslChannels(value: unknown): value is string {
return hslChannelsSchema.safeParse(value).success;
}
+3 -7
View File
@@ -3,14 +3,10 @@
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 { import { getAppUrl } from "~/lib/app-url";
// 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; function resolveAuthBaseUrl(): string | undefined {
return getAppUrl();
} }
export const authClient = createAuthClient({ export const authClient = createAuthClient({
+25 -276
View File
@@ -1,292 +1,41 @@
import { env } from "~/env"; import { env } from "~/env";
import { import { defaultColorMode, type ColorMode } from "~/lib/appearance";
fallbackAppearance,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type PdfTemplate,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/appearance";
export type {
ColorMode,
ColorTheme,
FontPreference,
InterfaceTheme,
PdfTemplate,
RadiusPreference,
SidebarStyle,
} from "~/lib/appearance";
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
export { export {
colorModeSchema, colorModeSchema,
colorThemeSchema, defaultColorMode,
fallbackAppearance, defaultPdfSettings,
fontPreferenceSchema,
hslChannelsSchema,
interfaceThemeSchema,
pdfTemplateSchema, pdfTemplateSchema,
radiusPreferenceSchema,
sidebarStyleSchema,
} from "~/lib/appearance"; } from "~/lib/appearance";
export const interfaceThemes: {
value: InterfaceTheme;
label: string;
description: string;
}[] = [
{
value: "beenvoice",
label: "beenvoice",
description:
"Playfair Display headings, Geist body text, and soft product chrome.",
},
{
value: "frutiger",
label: "Frutiger Airport",
description:
"Rectangular blue-and-yellow wayfinding UI with Frutiger typography and docked navigation.",
},
{
value: "frutiger-aero",
label: "Frutiger Aero",
description:
"Glossy sky-and-glass interface with Frutiger typography and softer surfaces.",
},
{
value: "shadcn",
label: "shadcn/ui",
description: "A plain shadcn baseline for white-label starts.",
},
{
value: "minimal",
label: "Minimal",
description: "Quiet surfaces, lower contrast, and restrained chrome.",
},
{
value: "editorial",
label: "Editorial",
description: "A warmer presentation style for service-led brands.",
},
];
export const themePresets: Record<
InterfaceTheme,
{
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
colorTheme: ColorTheme;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
}
> = {
beenvoice: {
interfaceTheme: "beenvoice",
bodyFontPreference: "brand",
headingFontPreference: "brand",
colorTheme: "slate",
radiusPreference: "xl",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
},
frutiger: {
interfaceTheme: "frutiger",
bodyFontPreference: "frutiger",
headingFontPreference: "frutiger",
colorTheme: "blue",
radiusPreference: "none",
sidebarStyle: "docked",
pdfTemplate: "minimal",
pdfAccentColor: "#003b5c",
},
"frutiger-aero": {
interfaceTheme: "frutiger-aero",
bodyFontPreference: "frutiger",
headingFontPreference: "frutiger",
colorTheme: "blue",
radiusPreference: "lg",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#0077be",
},
shadcn: {
interfaceTheme: "shadcn",
bodyFontPreference: "inter",
headingFontPreference: "inter",
colorTheme: "slate",
radiusPreference: "md",
sidebarStyle: "docked",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
},
minimal: {
interfaceTheme: "minimal",
bodyFontPreference: "platform",
headingFontPreference: "platform",
colorTheme: "slate",
radiusPreference: "sm",
sidebarStyle: "docked",
pdfTemplate: "minimal",
pdfAccentColor: "#111827",
},
editorial: {
interfaceTheme: "editorial",
bodyFontPreference: "platform",
headingFontPreference: "serif",
colorTheme: "rose",
radiusPreference: "lg",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#be123c",
},
};
export const bodyFontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Geist",
description: "Geist body text for the core beenvoice product feel.",
},
{
value: "frutiger",
label: "Frutiger",
description: "Frutiger body text for signage-like operational screens.",
},
{
value: "platform",
label: "Platform",
description: "Native system body text for the current OS.",
},
{
value: "inter",
label: "Geist Legacy",
description: "Legacy sans option mapped to Geist for older installs.",
},
{
value: "serif",
label: "Serif",
description: "Georgia-style body text for editorial deployments.",
},
];
export const headingFontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Playfair Display",
description: "Playfair Display headings for the beenvoice identity.",
},
{
value: "frutiger",
label: "Frutiger",
description: "Frutiger headings for airport-inspired wayfinding.",
},
{
value: "platform",
label: "Platform",
description: "Native system headings for a neutral app feel.",
},
{
value: "inter",
label: "Geist Legacy",
description: "Legacy sans option mapped to Geist for older installs.",
},
{
value: "serif",
label: "Editorial",
description: "Playfair headings with a stronger editorial tone.",
},
];
export const radiusPreferences: {
value: RadiusPreference;
label: string;
description: string;
}[] = [
{ value: "none", label: "Square", description: "No rounded corners." },
{ value: "sm", label: "Small", description: "Subtle 4px rounding." },
{ value: "md", label: "Medium", description: "Standard 8px rounding." },
{ value: "lg", label: "Large", description: "Soft 12px rounding." },
{
value: "xl",
label: "Extra Large",
description: "Expressive 16px rounding.",
},
];
export const sidebarStyles: {
value: SidebarStyle;
label: string;
description: string;
}[] = [
{
value: "floating",
label: "Floating",
description: "Inset navigation with rounded edges and elevation.",
},
{
value: "docked",
label: "Flush",
description: "Full-height navigation aligned to the viewport edge.",
},
];
export const colorThemes: {
value: ColorTheme;
label: string;
swatch: string;
}[] = [
{ value: "slate", label: "Slate", swatch: "hsl(240 5.9% 10%)" },
{ value: "blue", label: "Blue", swatch: "hsl(221.2 83.2% 53.3%)" },
{ value: "green", label: "Green", swatch: "hsl(142.1 76.2% 36.3%)" },
{ value: "rose", label: "Rose", swatch: "hsl(346.8 77.2% 49.8%)" },
{ value: "orange", label: "Orange", swatch: "hsl(24.6 95% 53.1%)" },
];
export const colorModes: { export const colorModes: {
value: ColorMode; value: ColorMode;
label: string; label: string;
description: string; description: string;
}[] = [ }[] = [
{ value: "system", label: "System", description: "Follow device setting." }, {
{ value: "light", label: "Light", description: "Always use light mode." }, value: "system",
{ value: "dark", label: "Dark", description: "Always use dark mode." }, label: "System",
description: "Match your device light or dark setting.",
},
{
value: "light",
label: "Light",
description: "Always use light mode.",
},
{
value: "dark",
label: "Dark",
description: "Always use dark mode.",
},
]; ];
export const defaultInterfaceTheme: InterfaceTheme =
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? fallbackAppearance.interfaceTheme;
export const defaultFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_FONT ?? fallbackAppearance.fontPreference;
export const defaultBodyFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference;
export const defaultHeadingFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference;
export const defaultRadiusPreference: RadiusPreference =
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? fallbackAppearance.radiusPreference;
export const defaultSidebarStyle: SidebarStyle =
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? fallbackAppearance.sidebarStyle;
export const brand = { export const brand = {
name: env.NEXT_PUBLIC_BRAND_NAME ?? fallbackAppearance.brandName, name: env.NEXT_PUBLIC_BRAND_NAME ?? "beenvoice",
tagline: env.NEXT_PUBLIC_BRAND_TAGLINE ?? fallbackAppearance.brandTagline, tagline:
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? fallbackAppearance.brandLogoText, env.NEXT_PUBLIC_BRAND_TAGLINE ??
icon: env.NEXT_PUBLIC_BRAND_ICON ?? fallbackAppearance.brandIcon, "Simple and efficient invoicing for freelancers and small businesses",
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
}; };
+44
View File
@@ -0,0 +1,44 @@
const DATABASE_SETUP_HINT =
"Database not ready — run `bun db:migrate` (or `bun db:push` for local dev) after starting Postgres.";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function collectErrorParts(error: unknown): string[] {
const parts: string[] = [];
const seen = new Set<unknown>();
let current: unknown = error;
while (current && isRecord(current) && !seen.has(current)) {
seen.add(current);
if (typeof current.message === "string") {
parts.push(current.message);
}
if (typeof current.code === "string") {
parts.push(current.code);
}
current = current.cause;
}
return parts;
}
export function getDatabaseSetupErrorMessage(error: unknown): string | null {
const haystack = collectErrorParts(error).join(" ").toLowerCase();
if (
haystack.includes("does not exist") ||
haystack.includes("42p01") ||
haystack.includes("econnrefused") ||
haystack.includes("connection refused") ||
haystack.includes("connect econnrefused")
) {
return DATABASE_SETUP_HINT;
}
return null;
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { getAppUrl } from "~/lib/app-url";
interface InvoiceEmailTemplateProps { interface InvoiceEmailTemplateProps {
invoice: { invoice: {
invoiceNumber: string; invoiceNumber: string;
@@ -44,7 +46,7 @@ export function generateInvoiceEmailTemplate({
customMessage, customMessage,
userName, userName,
userEmail, userEmail,
baseUrl: _baseUrl = "https://beenvoice.app", baseUrl = getAppUrl(),
}: InvoiceEmailTemplateProps): { html: string; text: string } { }: InvoiceEmailTemplateProps): { html: string; text: string } {
const formatDate = (date: Date) => { const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
@@ -1,4 +1,5 @@
import { formatEmailDate } from "src/lib/email-utils"; import { formatEmailDate } from "src/lib/email-utils";
import { SUPPORT_EMAIL } from "~/lib/app-email";
interface PasswordResetEmailProps { interface PasswordResetEmailProps {
userEmail: string; userEmail: string;
@@ -188,7 +189,7 @@ export function generatePasswordResetEmailTemplate({
</p> </p>
<p> <p>
beenvoice - Professional invoicing made simple<br> beenvoice - Professional invoicing made simple<br>
<a href="mailto:support@beenvoice.com">support@beenvoice.com</a> <a href="mailto:${SUPPORT_EMAIL}">${SUPPORT_EMAIL}</a>
</p> </p>
</div> </div>
</div> </div>
@@ -213,7 +214,7 @@ SECURITY INFORMATION:
If you're having trouble with the link, copy and paste the entire URL into your browser's address bar. If you're having trouble with the link, copy and paste the entire URL into your browser's address bar.
If you have any questions or need assistance, please contact our support team at support@beenvoice.com. If you have any questions or need assistance, please contact our support team at ${SUPPORT_EMAIL}.
Best regards, Best regards,
The beenvoice Team The beenvoice Team
+6 -3
View File
@@ -1,5 +1,8 @@
import { getAppUrl } from "~/lib/app-url";
import { LEGAL_EMAIL, PRIVACY_EMAIL } from "~/lib/app-email";
export const LEGAL_LAST_UPDATED = "June 18, 2026"; export const LEGAL_LAST_UPDATED = "June 18, 2026";
export const LEGAL_PRIVACY_EMAIL = "privacy@soconnor.dev"; export const LEGAL_PRIVACY_EMAIL = PRIVACY_EMAIL;
export const LEGAL_TERMS_EMAIL = "legal@soconnor.dev"; export const LEGAL_TERMS_EMAIL = LEGAL_EMAIL;
export const LEGAL_WEBSITE = "https://beenvoice.soconnor.dev"; export const LEGAL_WEBSITE = getAppUrl();
+13
View File
@@ -32,6 +32,19 @@ export function isNavLinkActive(pathname: string, href: string): boolean {
return pathname === href; return pathname === href;
} }
const ADMIN_ONLY_HREFS = new Set(["/dashboard/administration"]);
export function getNavigationForUser(isAdmin: boolean): NavSection[] {
return navigationConfig
.map((section) => ({
...section,
links: section.links.filter(
(link) => isAdmin || !ADMIN_ONLY_HREFS.has(link.href),
),
}))
.filter((section) => section.links.length > 0);
}
export const navigationConfig: NavSection[] = [ export const navigationConfig: NavSection[] = [
{ {
title: "Main", title: "Main",
+19 -5
View File
@@ -15,6 +15,9 @@ const PLURALIZATION_RULES: Record<
tax: { singular: "Tax", plural: "Taxes" }, tax: { singular: "Tax", plural: "Taxes" },
category: { singular: "Category", plural: "Categories" }, category: { singular: "Category", plural: "Categories" },
company: { singular: "Company", plural: "Companies" }, company: { singular: "Company", plural: "Companies" },
entity: { singular: "Entity", plural: "Entities" },
expense: { singular: "Expense", plural: "Expenses" },
report: { singular: "Report", plural: "Reports" },
}; };
/** /**
@@ -105,20 +108,31 @@ export function capitalize(word: string): string {
* Get a properly formatted label for a route segment * Get a properly formatted label for a route segment
*/ */
export function getRouteLabel(segment: string, isPlural = true): string { export function getRouteLabel(segment: string, isPlural = true): string {
// First, check if it's already in our rules const lower = segment.toLowerCase();
const rule = PLURALIZATION_RULES[segment.toLowerCase()];
// Route segments are often already plural (e.g. "entities", "invoices")
const ruleByPlural = Object.values(PLURALIZATION_RULES).find(
(r) => r.plural.toLowerCase() === lower,
);
if (ruleByPlural) {
return isPlural ? ruleByPlural.plural : ruleByPlural.singular;
}
const rule = PLURALIZATION_RULES[lower];
if (rule) { if (rule) {
return isPlural ? rule.plural : rule.singular; return isPlural ? rule.plural : rule.singular;
} }
// If not, try to find it by plural form
const singularForm = singularize(segment); const singularForm = singularize(segment);
const singularRule = PLURALIZATION_RULES[singularForm.toLowerCase()]; const singularRule = PLURALIZATION_RULES[singularForm.toLowerCase()];
if (singularRule) { if (singularRule) {
return isPlural ? singularRule.plural : singularRule.singular; return isPlural ? singularRule.plural : singularRule.singular;
} }
// Otherwise, just capitalize and optionally pluralize
const capitalized = capitalize(segment); const capitalized = capitalize(segment);
return isPlural ? pluralize(capitalized) : capitalized; if (isPlural && /s$/i.test(segment)) {
return capitalized;
}
return isPlural ? pluralize(capitalized) : capitalize(singularForm);
} }
+228 -139
View File
@@ -1,155 +1,244 @@
import { and, desc, eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices, clients } from "~/server/db/schema"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { and, desc, eq, isNotNull, lte } from "drizzle-orm"; import { clients, invoices } from "~/server/db/schema";
import type { StoredInvoiceStatus } from "~/types/invoice";
type LiteInvoice = {
id: string;
totalAmount: number;
status: string;
dueDate: Date;
issueDate: Date;
};
function buildRevenueMonthKeys(now: Date, count: number) {
const keys: string[] = [];
for (let i = count - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
keys.push(
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
);
}
return keys;
}
function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
let totalRevenue = 0;
let pendingAmount = 0;
let overdueCount = 0;
let currentMonthRevenue = 0;
let lastMonthRevenue = 0;
const revenueByMonth = Object.fromEntries(
buildRevenueMonthKeys(now, 6).map((key) => [key, 0]),
) as Record<string, number>;
const statusTotals: Record<
string,
{ status: string; count: number; value: number }
> = {};
const monthlyTotals: Record<
string,
{
month: string;
totalInvoices: number;
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
}
> = {};
for (const inv of userInvoices) {
const effectiveStatus = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
const amount = inv.totalAmount;
const issueDate = new Date(inv.issueDate);
if (effectiveStatus === "paid") {
totalRevenue += amount;
if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount;
} else if (
issueDate >= lastMonthStart &&
issueDate < currentMonthStart
) {
lastMonthRevenue += amount;
}
const revenueKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
const monthRevenue = revenueByMonth[revenueKey];
if (monthRevenue !== undefined) {
revenueByMonth[revenueKey] = monthRevenue + amount;
}
} else if (effectiveStatus === "sent" || effectiveStatus === "overdue") {
pendingAmount += amount;
}
if (effectiveStatus === "overdue") {
overdueCount++;
}
statusTotals[effectiveStatus] ??= {
status: effectiveStatus,
count: 0,
value: 0,
};
statusTotals[effectiveStatus].count += 1;
statusTotals[effectiveStatus].value += amount;
const monthKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
monthlyTotals[monthKey] ??= {
month: monthKey,
totalInvoices: 0,
paidInvoices: 0,
pendingInvoices: 0,
overdueInvoices: 0,
draftInvoices: 0,
};
monthlyTotals[monthKey].totalInvoices += 1;
switch (effectiveStatus) {
case "paid":
monthlyTotals[monthKey].paidInvoices += 1;
break;
case "sent":
monthlyTotals[monthKey].pendingInvoices += 1;
break;
case "overdue":
monthlyTotals[monthKey].overdueInvoices += 1;
break;
case "draft":
monthlyTotals[monthKey].draftInvoices += 1;
break;
}
}
const revenueChartData = Object.entries(revenueByMonth)
.map(([month, revenue]) => ({
month,
revenue,
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}))
.sort((a, b) => a.month.localeCompare(b.month));
const statusChartData = Object.values(statusTotals).map((item) => ({
...item,
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
}));
const monthlyMetricsChartData = Object.values(monthlyTotals)
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-6)
.map((item) => ({
...item,
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}));
return {
totalRevenue,
pendingAmount,
overdueCount,
revenueChange:
lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0,
revenueChartData,
statusChartData,
monthlyMetricsChartData,
};
}
export const dashboardRouter = createTRPCRouter({ export const dashboardRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => { getStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
const now = new Date(); const now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
// 1. Fetch all invoices for the user to calculate stats const [
// Note: For very large datasets, we should use separate count/sum queries, userInvoices,
// but for typical usage, fetching fields is fine and allows flexible JS calculation userClientsCount,
// where SQL complexity might be high (e.g. dynamic status). recentInvoices,
// However, let's try to be efficient with SQL where possible. currentDraft,
] = await Promise.all([
const userInvoices = await ctx.db.query.invoices.findMany({ ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId), where: eq(invoices.createdById, userId),
columns: { columns: {
id: true, id: true,
totalAmount: true, totalAmount: true,
status: true, status: true,
dueDate: true, dueDate: true,
issueDate: true, issueDate: true,
},
});
const userClientsCount = await ctx.db.$count(
clients,
eq(clients.createdById, userId),
);
// Helper to check status
const getStatus = (inv: (typeof userInvoices)[0]) => {
if (inv.status === "paid") return "paid";
if (inv.status === "draft") return "draft";
if (new Date(inv.dueDate) < now && inv.status !== "paid")
return "overdue";
return "sent";
};
// Calculate Stats
let totalRevenue = 0;
let pendingAmount = 0;
let overdueCount = 0;
let currentMonthRevenue = 0;
let lastMonthRevenue = 0;
for (const inv of userInvoices) {
const status = getStatus(inv);
const amount = inv.totalAmount;
const issueDate = new Date(inv.issueDate);
if (status === "paid") {
totalRevenue += amount;
if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount;
} else if (
issueDate >= lastMonthStart &&
issueDate < currentMonthStart
) {
lastMonthRevenue += amount;
}
} else if (status === "sent" || status === "overdue") {
pendingAmount += amount;
}
if (status === "overdue") {
overdueCount++;
}
}
// Revenue Trend (Last 6 months)
const revenueByMonth: Record<string, number> = {};
for (let i = 0; i < 6; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
revenueByMonth[key] = 0;
}
for (const inv of userInvoices) {
if (getStatus(inv) === "paid") {
const d = new Date(inv.issueDate);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
if (revenueByMonth[key] !== undefined) {
revenueByMonth[key] += inv.totalAmount;
}
}
}
const revenueChartData = Object.entries(revenueByMonth)
.map(([month, revenue]) => ({
month,
revenue,
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}))
.sort((a, b) => a.month.localeCompare(b.month));
// Recent Activity
const recentInvoices = await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
limit: 5,
with: {
client: {
columns: { name: true },
}, },
}, }),
}); ctx.db.$count(clients, eq(clients.createdById, userId)),
ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
limit: 5,
with: {
client: {
columns: { name: true },
},
},
}),
ctx.db.query.invoices.findFirst({
where: and(
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
columns: {
id: true,
invoiceNumber: true,
totalAmount: true,
},
with: {
client: { columns: { name: true } },
items: { columns: { hours: true } },
},
}),
]);
const sendReminderDue = await ctx.db.query.invoices.findMany({ const metrics = aggregateDashboardMetrics(userInvoices, now);
where: and(
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
isNotNull(invoices.sendReminderAt),
lte(invoices.sendReminderAt, now),
),
columns: {
id: true,
invoiceNumber: true,
invoicePrefix: true,
sendReminderAt: true,
},
with: {
client: { columns: { name: true } },
},
orderBy: [desc(invoices.sendReminderAt)],
limit: 10,
});
return { return {
totalRevenue, ...metrics,
pendingAmount,
overdueCount,
totalClients: userClientsCount, totalClients: userClientsCount,
revenueChange:
lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0,
revenueChartData,
recentInvoices, recentInvoices,
sendReminderDue, currentDraft: currentDraft
? {
id: currentDraft.id,
invoiceNumber: currentDraft.invoiceNumber,
totalAmount: currentDraft.totalAmount,
client: currentDraft.client,
totalHours: currentDraft.items.reduce(
(sum, item) => sum + item.hours,
0,
),
}
: null,
}; };
}), }),
}); });
+4 -2
View File
@@ -4,6 +4,8 @@ import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices, platformSettings } from "~/server/db/schema"; import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { env } from "~/env"; import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url";
import { generateInvoicePDFBlob } from "~/lib/pdf-export"; import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates"; import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
@@ -153,7 +155,7 @@ export const emailRouter = createTRPCRouter({
customMessage, customMessage,
userName, userName,
userEmail, userEmail,
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000", baseUrl: getAppUrl(),
}); });
// Determine Resend instance and email configuration to use // Determine Resend instance and email configuration to use
@@ -177,7 +179,7 @@ export const emailRouter = createTRPCRouter({
fromEmail = `noreply@${env.RESEND_DOMAIN}`; fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) { } else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY); resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? "noreply@example.com"; fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
} else { } else {
throw new Error( throw new Error(
"Email delivery is not configured. Add a Resend API key globally or on this business.", "Email delivery is not configured. Add a Resend API key globally or on this business.",
+2 -1
View File
@@ -12,6 +12,7 @@ import { TRPCError } from "@trpc/server";
import { generateInvoicePDFBlob } from "~/lib/pdf-export"; import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { Resend } from "resend"; import { Resend } from "resend";
import { env } from "~/env"; import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email"; import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
import type { db } from "~/server/db"; import type { db } from "~/server/db";
@@ -844,7 +845,7 @@ export const invoicesRouter = createTRPCRouter({
fromEmail = `noreply@${env.RESEND_DOMAIN}`; fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) { } else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY); resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? "noreply@example.com"; fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
} else { } else {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
File diff suppressed because it is too large Load Diff
+3 -8
View File
@@ -133,18 +133,13 @@ export const createTRPCRouter = t.router;
*/ */
const timingMiddleware = t.middleware(async ({ next, path }) => { const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now(); const start = Date.now();
const result = await next();
const end = Date.now();
if (t._config.isDev) { if (t._config.isDev) {
// artificial delay in dev console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
} }
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
return result; return result;
}); });
+1 -25
View File
@@ -32,37 +32,13 @@ export const users = createTable("user", (d) => ({
// Custom fields // Custom fields
prefersReducedMotion: d.boolean().default(false).notNull(), prefersReducedMotion: d.boolean().default(false).notNull(),
animationSpeedMultiplier: d.real().default(1).notNull(), animationSpeedMultiplier: d.real().default(1).notNull(),
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
customColor: d.varchar({ length: 50 }),
theme: d.varchar({ length: 20 }).default("system").notNull(), theme: d.varchar({ length: 20 }).default("system").notNull(),
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
fontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
role: d.varchar({ length: 20 }).default("user").notNull(), role: d.varchar({ length: 20 }).default("user").notNull(),
onboardingCompletedAt: d.timestamp(),
})); }));
export const platformSettings = createTable("platform_setting", (d) => ({ export const platformSettings = createTable("platform_setting", (d) => ({
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"), id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
brandName: d.varchar({ length: 100 }).default("beenvoice").notNull(),
brandTagline: d
.varchar({ length: 255 })
.default(
"Simple and efficient invoicing for freelancers and small businesses",
)
.notNull(),
brandLogoText: d.varchar({ length: 100 }).default("beenvoice").notNull(),
brandIcon: d.varchar({ length: 20 }).default("$").notNull(),
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
customColor: d.varchar({ length: 50 }),
theme: d.varchar({ length: 20 }).default("system").notNull(),
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(), pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(), pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
pdfFooterText: d pdfFooterText: d
+11 -577
View File
@@ -3,218 +3,35 @@
@layer base { @layer base {
:root { :root {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
--background: 0 0% 100%; --background: 0 0% 100%;
/* #FFFFFF */
--foreground: 240 10% 3.9%; --foreground: 240 10% 3.9%;
/* #09090B */
--card: 0 0% 100%; --card: 0 0% 100%;
/* #FFFFFF */
--card-foreground: 240 10% 3.9%; --card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%; --popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%; --primary: 240 5.9% 10%;
/* #18181B */
--primary-foreground: 0 0% 98%; --primary-foreground: 0 0% 98%;
/* #FAFAFA */
--secondary: 240 4.8% 90%; --secondary: 240 4.8% 90%;
/* #E4E4E7 (Darkened for contrast) */
--secondary-foreground: 240 5.9% 10%; --secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%; --muted: 240 4.8% 95.9%;
/* #F4F4F5 */
--muted-foreground: 240 3.8% 46.1%; --muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%; --accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%; --accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%; --border: 240 5.9% 90%;
/* #E4E4E7 */
--input: 240 5.9% 90%; --input: 240 5.9% 90%;
--ring: 240 10% 3.9%; --ring: 240 10% 3.9%;
--radius: 1rem; --radius: 1rem;
/* 16px Global Radius */
}
:root[data-interface-theme="shadcn"] {
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--radius: 0.5rem;
}
:root[data-interface-theme="beenvoice"] {
--secondary: 240 4.8% 90%;
--secondary-foreground: 240 5.9% 10%;
--radius: 1rem;
}
:root[data-interface-theme="frutiger"] {
--background: 0 0% 100%;
--foreground: 210 24% 8%;
--card: 0 0% 100%;
--card-foreground: 210 24% 8%;
--popover: 0 0% 100%;
--popover-foreground: 210 24% 8%;
--primary: 203 100% 18%;
--primary-foreground: 46 100% 91%;
--secondary: 47 100% 50%;
--secondary-foreground: 210 24% 8%;
--muted: 199 73% 91%;
--muted-foreground: 203 52% 24%;
--accent: 47 100% 50%;
--accent-foreground: 210 24% 8%;
--border: 203 45% 42%;
--input: 203 45% 42%;
--ring: 203 100% 18%;
--radius: 0rem;
}
:root[data-interface-theme="frutiger-aero"] {
--background: 190 76% 96%;
--foreground: 205 50% 12%;
--card: 0 0% 100%;
--card-foreground: 205 50% 12%;
--popover: 0 0% 100%;
--popover-foreground: 205 50% 12%;
--primary: 201 100% 37%;
--primary-foreground: 0 0% 100%;
--secondary: 104 55% 55%;
--secondary-foreground: 205 50% 12%;
--muted: 190 56% 90%;
--muted-foreground: 205 32% 32%;
--accent: 104 55% 55%;
--accent-foreground: 205 50% 12%;
--border: 195 48% 74%;
--input: 195 48% 74%;
--ring: 201 100% 37%;
--radius: 0.75rem;
}
:root[data-interface-theme="minimal"] {
--background: 0 0% 100%;
--card: 0 0% 100%;
--popover: 0 0% 100%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 96.5%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 97%;
--accent: 240 4.8% 96%;
--accent-foreground: 240 5.9% 10%;
}
:root[data-interface-theme="editorial"] {
--background: 36 33% 98%;
--card: 36 33% 99%;
--popover: 36 33% 99%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 30 18% 91%;
--secondary-foreground: 24 10% 10%;
--muted: 30 20% 94%;
--accent: 346.8 77.2% 49.8%;
--accent-foreground: 355.7 100% 97.3%;
--border: 30 15% 86%;
--input: 30 15% 86%;
}
:root[data-body-font="brand"],
:root[data-body-font="inter"] {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
:root[data-body-font="frutiger"] {
--app-font-sans: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
}
:root[data-body-font="platform"] {
--app-font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-body-font="serif"] {
--app-font-sans:
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
}
:root[data-heading-font="brand"],
:root[data-heading-font="serif"] {
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-heading-font="frutiger"] {
--app-font-heading: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
}
:root[data-heading-font="platform"] {
--app-font-heading:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-heading-font="inter"] {
--app-font-heading: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="brand"]:not([data-body-font]) {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-font="frutiger"]:not([data-body-font]) {
--app-font-sans: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="platform"]:not([data-body-font]) {
--app-font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
--app-font-heading:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-font="inter"]:not([data-body-font]) {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="serif"]:not([data-body-font]) {
--app-font-sans:
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-radius="none"] {
--radius: 0rem;
}
:root[data-radius="sm"] {
--radius: 0.25rem;
}
:root[data-radius="md"] {
--radius: 0.5rem;
}
:root[data-radius="lg"] {
--radius: 0.75rem;
}
:root[data-radius="xl"] {
--radius: 1rem;
} }
:root[data-color-mode="dark"], :root[data-color-mode="dark"],
:root.dark { :root.dark {
--background: 240 10% 3.9%; --background: 240 10% 3.9%;
/* #09090B */
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
/* #FAFAFA */
--card: 240 10% 3.9%; --card: 240 10% 3.9%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%; --popover: 240 10% 3.9%;
@@ -222,7 +39,6 @@
--primary: 0 0% 98%; --primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%; --primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 20%; --secondary: 240 3.7% 20%;
/* #27272A */
--secondary-foreground: 0 0% 98%; --secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%; --muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%; --muted-foreground: 240 5% 64.9%;
@@ -231,7 +47,6 @@
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%; --border: 240 3.7% 15.9%;
/* #27272A */
--input: 240 3.7% 15.9%; --input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%; --ring: 240 4.9% 83.9%;
} }
@@ -239,9 +54,7 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-color-mode="light"]) { :root:not([data-color-mode="light"]) {
--background: 240 10% 3.9%; --background: 240 10% 3.9%;
/* #09090B */
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
/* #FAFAFA */
--card: 240 10% 3.9%; --card: 240 10% 3.9%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%; --popover: 240 10% 3.9%;
@@ -249,7 +62,6 @@
--primary: 0 0% 98%; --primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%; --primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 20%; --secondary: 240 3.7% 20%;
/* #27272A */
--secondary-foreground: 0 0% 98%; --secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%; --muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%; --muted-foreground: 240 5% 64.9%;
@@ -258,110 +70,10 @@
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%; --border: 240 3.7% 15.9%;
/* #27272A */
--input: 240 3.7% 15.9%; --input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%; --ring: 240 4.9% 83.9%;
} }
} }
:root[data-color-theme="slate"] {
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
}
:root[data-color-theme="blue"] {
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--accent: 217.2 91.2% 59.8%;
--accent-foreground: 210 40% 98%;
}
:root[data-color-theme="green"] {
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--accent: 142.1 70.6% 45.3%;
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-color-theme="rose"] {
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--accent: 346.8 77.2% 49.8%;
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-color-theme="orange"] {
--primary: 24.6 95% 53.1%;
--primary-foreground: 60 9.1% 97.8%;
--accent: 20.5 90.2% 48.2%;
--accent-foreground: 60 9.1% 97.8%;
}
:root[data-color-theme="custom"] {
--primary: var(--custom-primary, 142.1 76.2% 36.3%);
--primary-foreground: 355.7 100% 97.3%;
--accent: var(--custom-primary, 142.1 76.2% 36.3%);
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-interface-theme="frutiger"] {
--background: 0 0% 100%;
--foreground: 210 24% 8%;
--card: 0 0% 100%;
--card-foreground: 210 24% 8%;
--popover: 0 0% 100%;
--popover-foreground: 210 24% 8%;
--primary: 203 100% 18%;
--primary-foreground: 46 100% 91%;
--accent: 47 100% 50%;
--accent-foreground: 210 24% 8%;
--secondary: 47 100% 50%;
--secondary-foreground: 210 24% 8%;
--muted: 199 73% 91%;
--muted-foreground: 203 52% 24%;
--border: 203 45% 42%;
--input: 203 45% 42%;
--ring: 203 100% 18%;
}
:root[data-interface-theme="frutiger-aero"] {
--background: 190 76% 96%;
--foreground: 205 50% 12%;
--card: 0 0% 100%;
--card-foreground: 205 50% 12%;
--popover: 0 0% 100%;
--popover-foreground: 205 50% 12%;
--primary: 201 100% 37%;
--primary-foreground: 0 0% 100%;
--accent: 104 55% 55%;
--accent-foreground: 205 50% 12%;
--secondary: 104 55% 55%;
--secondary-foreground: 205 50% 12%;
--muted: 190 56% 90%;
--muted-foreground: 205 32% 32%;
--border: 195 48% 74%;
--input: 195 48% 74%;
--ring: 201 100% 37%;
}
:root[data-color-mode="dark"][data-color-theme="slate"],
:root.dark[data-color-theme="slate"] {
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
}
@media (prefers-color-scheme: dark) {
:root:not([data-color-mode="light"])[data-color-theme="slate"] {
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
}
}
} }
@theme inline { @theme inline {
@@ -415,298 +127,39 @@
} }
@layer utilities { @layer utilities {
:root[data-interface-theme="shadcn"] .brand-background, .dashboard-content-shell {
:root[data-interface-theme="minimal"] .brand-background {
display: none;
}
:root[data-interface-theme="frutiger"] .brand-background {
display: none;
}
:root[data-interface-theme="frutiger-aero"] .brand-background {
display: flex;
background:
radial-gradient(circle at 18% 22%, hsl(104 55% 55% / 0.35), transparent 32%),
radial-gradient(circle at 82% 18%, hsl(190 88% 66% / 0.42), transparent 30%),
linear-gradient(180deg, hsl(195 100% 94% / 0.95), hsl(0 0% 100% / 0.35));
}
:root[data-interface-theme="frutiger"] .dashboard-content-shell {
padding: 1rem; padding: 1rem;
padding-top: 4rem; padding-top: 4rem;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
:root[data-interface-theme="frutiger"] .dashboard-content-shell { .dashboard-content-shell {
padding: 1.25rem; padding: 1.25rem;
padding-top: 1.25rem;
} }
} }
:root[data-interface-theme="frutiger"] .bg-dashboard { .platform-header-content {
background: hsl(var(--background));
}
:root[data-interface-theme="frutiger-aero"] .bg-dashboard {
background:
radial-gradient(circle at 78% 8%, hsl(104 55% 55% / 0.22), transparent 26rem),
linear-gradient(180deg, hsl(190 76% 96%) 0%, hsl(0 0% 100%) 72%);
}
:root[data-interface-theme="frutiger"] aside {
border-color: hsl(var(--primary));
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
:root[data-interface-theme="frutiger"] aside [class*="text-muted-foreground"] {
color: hsl(var(--primary-foreground) / 0.72);
}
:root[data-interface-theme="frutiger"] aside a {
color: hsl(var(--primary-foreground) / 0.86);
}
:root[data-interface-theme="frutiger"] aside a:hover,
:root[data-interface-theme="frutiger"] aside a[data-active="true"] {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
:root[data-interface-theme="frutiger"] aside button {
color: hsl(var(--primary-foreground) / 0.82);
}
:root[data-interface-theme="frutiger"] .dashboard-mobile-header {
border-color: hsl(var(--primary));
background-color: hsl(var(--accent));
color: hsl(var(--foreground));
backdrop-filter: none;
}
:root[data-interface-theme="frutiger"] .platform-header-surface {
border-color: hsl(var(--primary));
border-top: 0.5rem solid hsl(var(--primary));
background-color: hsl(var(--accent));
color: hsl(var(--foreground));
box-shadow: none;
}
:root[data-interface-theme="frutiger"] .platform-header-gradient {
display: none !important;
background: none !important;
}
:root[data-interface-theme="frutiger"] .platform-header-surface .text-primary,
:root[data-interface-theme="frutiger"] .platform-header-surface [class*="text-primary"],
:root[data-interface-theme="frutiger"] .dashboard-mobile-header .text-primary,
:root[data-interface-theme="frutiger"] .dashboard-mobile-header [class*="text-primary"] {
color: hsl(var(--foreground));
}
:root[data-interface-theme="frutiger"] .platform-header-surface .text-muted-foreground,
:root[data-interface-theme="frutiger"] .platform-header-surface [class*="text-muted-foreground"] {
color: hsl(var(--foreground) / 0.72);
}
:root[data-interface-theme="frutiger-aero"] aside {
border-color: hsl(190 88% 66% / 0.55);
background:
linear-gradient(180deg, hsl(201 100% 37% / 0.82), hsl(190 88% 45% / 0.72)),
hsl(201 100% 37% / 0.78);
color: hsl(var(--primary-foreground));
box-shadow: 0 18px 36px -20px hsl(201 100% 22% / 0.55);
backdrop-filter: blur(18px) saturate(1.35);
}
:root[data-interface-theme="frutiger-aero"] aside [class*="text-muted-foreground"] {
color: hsl(var(--primary-foreground) / 0.76);
}
:root[data-interface-theme="frutiger-aero"] aside a {
color: hsl(var(--primary-foreground) / 0.88);
}
:root[data-interface-theme="frutiger-aero"] aside a:hover,
:root[data-interface-theme="frutiger-aero"] aside a[data-active="true"] {
background-color: hsl(0 0% 100% / 0.92);
color: hsl(var(--primary));
}
:root[data-interface-theme="frutiger-aero"] .dashboard-mobile-header {
border-color: hsl(190 88% 66% / 0.55);
background-color: hsl(0 0% 100% / 0.78);
color: hsl(var(--foreground));
backdrop-filter: blur(18px) saturate(1.35);
}
:root[data-interface-theme="frutiger-aero"] .platform-header-surface {
border-color: hsl(190 88% 66% / 0.6);
background:
linear-gradient(180deg, hsl(0 0% 100% / 0.88), hsl(190 88% 92% / 0.72)),
hsl(0 0% 100% / 0.72);
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.9),
0 18px 36px -28px hsl(201 100% 37% / 0.6);
backdrop-filter: blur(16px) saturate(1.35);
}
:root[data-interface-theme="frutiger-aero"] .platform-header-gradient {
display: block;
background: radial-gradient(circle at 92% 10%, hsl(104 55% 55% / 0.45), transparent 34%),
linear-gradient(135deg, hsl(190 88% 66% / 0.28), transparent 48%);
opacity: 1;
}
:root[data-interface-theme="frutiger"] [data-slot="card"],
:root[data-interface-theme="frutiger"] [data-slot="dialog-content"],
:root[data-interface-theme="frutiger"] [data-slot="popover-content"],
:root[data-interface-theme="frutiger"] [data-slot="select-content"] {
border-color: hsl(var(--primary) / 0.45);
border-radius: 0;
background-color: hsl(var(--card));
box-shadow: none;
}
:root[data-interface-theme="frutiger"] [data-slot="card"] {
border-top: 0.35rem solid hsl(var(--accent));
}
:root[data-interface-theme="frutiger-aero"] [data-slot="card"] {
border-color: hsl(190 88% 66% / 0.45);
background-color: hsl(0 0% 100% / 0.78);
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.9),
0 16px 34px -30px hsl(201 100% 37% / 0.7);
backdrop-filter: blur(14px) saturate(1.25);
}
:root[data-interface-theme="frutiger"] [data-slot="button"],
:root[data-interface-theme="frutiger"] button,
:root[data-interface-theme="frutiger"] input,
:root[data-interface-theme="frutiger"] textarea,
:root[data-interface-theme="frutiger"] [role="combobox"] {
border-radius: 0;
}
:root[data-interface-theme="frutiger"] .button-hover:hover,
:root[data-interface-theme="frutiger"] .card-hover:hover {
transform: none;
box-shadow: none;
}
:root[data-interface-theme="frutiger"] [data-slot="card-header"],
:root[data-interface-theme="frutiger"] [data-slot="card-content"],
:root[data-interface-theme="frutiger"] [data-slot="card-footer"] {
padding-inline: 1rem;
}
:root[data-interface-theme="minimal"] [data-slot="card"] {
background-color: transparent;
border-color: transparent;
border-radius: 0;
border-top-color: hsl(var(--border));
box-shadow: none;
backdrop-filter: none;
overflow: visible;
}
:root[data-interface-theme="minimal"] [data-slot="card"] + [data-slot="card"],
:root[data-interface-theme="minimal"] .form-section + .form-section {
border-top: 1px solid hsl(var(--border));
padding-top: 1rem;
}
:root[data-interface-theme="minimal"] [data-slot="card-header"],
:root[data-interface-theme="minimal"] [data-slot="card-content"],
:root[data-interface-theme="minimal"] [data-slot="card-footer"] {
padding-inline: 0;
}
:root[data-interface-theme="minimal"] [data-slot="card-header"] {
padding-top: 0.75rem;
padding-bottom: 0.5rem;
}
:root[data-interface-theme="minimal"] [data-slot="card-content"] {
padding-bottom: 0.75rem;
}
:root[data-interface-theme="minimal"] .page-enter,
:root[data-interface-theme="minimal"] [class*="space-y-8"],
:root[data-interface-theme="minimal"] [class*="space-y-6"] {
row-gap: 1rem;
}
:root[data-interface-theme="minimal"]
[class*="space-y-8"]
> :not([hidden])
~ :not([hidden]),
:root[data-interface-theme="minimal"]
[class*="space-y-6"]
> :not([hidden])
~ :not([hidden]) {
margin-top: 1rem;
}
:root[data-interface-theme="minimal"] [class*="gap-6"] {
gap: 1rem;
}
:root[data-interface-theme="minimal"] .platform-header-surface {
background-color: transparent;
border-color: transparent;
box-shadow: none;
backdrop-filter: none;
overflow: visible;
}
:root[data-interface-theme="minimal"] .platform-header-content {
padding: 0;
}
:root[data-interface-theme="minimal"] .platform-header-gradient {
display: none;
}
:root[data-interface-theme="minimal"] .bg-dashboard {
background-color: hsl(var(--background));
}
:root[data-interface-theme="beenvoice"] .dashboard-content-shell {
padding: 0.75rem;
padding-top: 4rem;
}
@media (min-width: 768px) {
:root[data-interface-theme="beenvoice"] .dashboard-content-shell {
padding-top: 0.75rem;
}
}
:root[data-interface-theme="beenvoice"] .platform-header-content {
padding: 1.25rem; padding: 1.25rem;
} }
:root[data-interface-theme="beenvoice"] [data-slot="card"] { [data-slot="card"] {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
:root[data-interface-theme="beenvoice"] [data-slot="card-header"] { [data-slot="card-header"] {
padding: 1rem 1rem 0.75rem; padding: 1rem 1rem 0.75rem;
} }
:root[data-interface-theme="beenvoice"] [data-slot="card-content"] { [data-slot="card-content"] {
padding-inline: 1rem; padding-inline: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
} }
:root[data-interface-theme="beenvoice"] [data-slot="card-footer"] { [data-slot="card-footer"] {
padding: 1rem; padding: 1rem;
} }
:root[data-interface-theme="editorial"] .brand-background {
opacity: 0.55;
}
.animate-blob { .animate-blob {
animation: blob 7s infinite; animation: blob 7s infinite;
} }
@@ -728,25 +181,6 @@
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1); box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1);
} }
:root[data-radius] .rounded-sm {
border-radius: var(--radius-sm);
}
:root[data-radius] .rounded,
:root[data-radius] .rounded-md {
border-radius: var(--radius-md);
}
:root[data-radius] .rounded-lg {
border-radius: var(--radius-lg);
}
:root[data-radius] .rounded-xl,
:root[data-radius] .rounded-2xl,
:root[data-radius] .rounded-3xl {
border-radius: var(--radius-xl);
}
} }
@keyframes blob { @keyframes blob {
+2 -3
View File
@@ -8,6 +8,7 @@ import { useState } from "react";
import SuperJSON from "superjson"; import SuperJSON from "superjson";
import { createQueryClient } from "./query-client"; import { createQueryClient } from "./query-client";
import { getAppUrl } from "~/lib/app-url";
import type { AppRouter } from "~/server/api/root"; import type { AppRouter } from "~/server/api/root";
let clientQueryClientSingleton: QueryClient | undefined = undefined; let clientQueryClientSingleton: QueryClient | undefined = undefined;
@@ -72,7 +73,5 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
} }
function getBaseUrl() { function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin; return getAppUrl();
if (process.env.NEXT_PUBLIC_APP_URL) return process.env.NEXT_PUBLIC_APP_URL;
return `http://localhost:${process.env.PORT ?? 3000}`;
} }