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
+6 -2
View File
@@ -4,6 +4,8 @@ import { db } from "~/server/db";
import { users } from "~/server/db/schema";
import { Resend } from "resend";
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 crypto from "crypto";
@@ -72,7 +74,7 @@ export async function POST(request: NextRequest) {
// Send password reset email using Resend
try {
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({
userEmail: email,
@@ -82,8 +84,10 @@ export async function POST(request: NextRequest) {
expiryHours: 24,
});
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
await resend.emails.send({
from: "beenvoice <noreply@beenvoice.com>",
from: `beenvoice <noreply@${fromDomain}>`,
to: email,
subject: emailTemplate.subject,
html: emailTemplate.html,
+7
View File
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "~/lib/auth";
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
import { env } from "~/env";
import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema";
@@ -161,6 +162,12 @@ export async function POST(request: NextRequest) {
);
} catch (error) {
console.error("Registration error:", error);
const databaseSetupError = getDatabaseSetupErrorMessage(error);
if (databaseSetupError) {
return NextResponse.json({ error: databaseSetupError }, { status: 503 });
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
+2 -1
View File
@@ -3,6 +3,7 @@ import { z, type ZodType } from "zod";
import { createCaller } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
import { getAppUrl } from "~/lib/app-url";
export const runtime = "nodejs";
@@ -856,7 +857,7 @@ const tools = {
schema: z.object({ id: z.string(), ttlHours: z.number().positive().optional() }),
handler: async (input, caller) => {
const result = await caller.invoices.generatePublicToken(input);
const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const base = getAppUrl();
return {
...result,
webUrl: `${base}/i/${result.token}`,
+2 -210
View File
@@ -1,213 +1,5 @@
"use client";
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>
);
}
import { RegisterForm } from "./register-form";
export default function RegisterPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<RegisterForm />
</Suspense>
);
return <RegisterForm />;
}
+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 { toast } from "sonner";
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 { data: running, isLoading } = api.timeEntries.getRunning.useQuery(undefined, {
refetchInterval: 30_000,
});
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(
undefined,
{
staleTime: 60_000,
refetchOnWindowFocus: false,
refetchInterval: 60_000,
},
);
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -56,9 +76,6 @@ export function ActiveTimerWidget() {
}
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),
});
@@ -69,64 +86,153 @@ export function ActiveTimerWidget() {
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: 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 (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<span className="relative flex h-3 w-3 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-3 w-3 rounded-full" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{running.description || (
<span className="text-muted-foreground italic">No description</span>
)}
{running.client && (
<span className="text-muted-foreground font-normal"> · {running.client.name}</span>
)}
</p>
<p className="text-muted-foreground text-xs">
{invoiceLabel ? (
<>
Billing to{" "}
<Link
href={`/dashboard/invoices/${running.invoice!.id}`}
className="text-primary hover:underline"
>
{invoiceLabel}
</Link>
</>
) : (
<>No invoice selected open time clock to assign</>
)}
{" · "}
<Link href="/dashboard/time-clock" className="text-primary hover:underline">
Time clock
</Link>
</p>
<CardContent className="flex flex-col gap-3 p-3">
<div className="flex items-start gap-2">
<span className="relative mt-1 flex h-2.5 w-2.5 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.5 w-2.5 rounded-full" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug font-medium">
{description}
{running.client && (
<span className="text-muted-foreground font-normal">
{" "}
· {running.client.name}
</span>
)}
</p>
<p className="text-muted-foreground mt-1 text-xs leading-snug">
{invoiceLabel ? (
<>
Billing to{" "}
<Link
href={`/dashboard/invoices/${running.invoice!.id}`}
className="text-primary hover:underline"
>
{invoiceLabel}
</Link>
</>
) : (
<>No invoice selected open time clock to assign</>
)}
{" · "}
<Link href="/dashboard/time-clock" className="text-primary hover:underline">
Time clock
</Link>
</p>
</div>
</div>
<span className="text-primary font-mono text-2xl font-bold tabular-nums">
{formatElapsedSeconds(elapsed)}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/dashboard/time-clock">
<Clock className="mr-1.5 h-3.5 w-3.5" />
Open
</Link>
</Button>
<Button
variant="destructive"
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 className="flex flex-col items-center gap-2">
<span className="text-primary text-center font-mono text-xl font-bold tabular-nums">
{formatElapsedSeconds(elapsed)}
</span>
<div className="flex w-full flex-col gap-1.5">
<Button variant="outline" size="sm" asChild className="h-8 w-full">
<Link href="/dashboard/time-clock">
<Clock className="mr-1 h-3.5 w-3.5" />
Open
</Link>
</Button>
{renderStopButton("w-full")}
</div>
</div>
</CardContent>
</Card>
@@ -8,7 +8,14 @@ import {
Clock,
Users,
} 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";
@@ -51,41 +58,36 @@ export function AnimatedStatsCard({
const isPositive = trend === "up";
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 isCurrency;
void numericValue;
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-2">
<Icon className="text-muted-foreground h-5 w-5" />
<p className="text-muted-foreground text-sm font-medium">{title}</p>
</div>
<div
className="flex items-center space-x-1 text-xs"
style={{
color: isNeutral
? "hsl(var(--muted-foreground))"
: isPositive
? "oklch(var(--chart-2))"
: "oklch(var(--chart-3))",
}}
>
<TrendIcon className="h-3 w-3" />
<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>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<CardTitle className="text-muted-foreground flex items-center gap-2 text-sm font-medium">
<Icon className="h-4 w-4" />
{title}
</CardTitle>
<div
className={cn(
"flex items-center gap-1 text-xs font-medium",
isNeutral
? "text-muted-foreground"
: isPositive
? "text-emerald-600 dark:text-emerald-400"
: "text-amber-600 dark:text-amber-400",
)}
>
<TrendIcon className="h-3 w-3" />
<span className="font-mono tabular-nums">{change}</span>
</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>
</Card>
);
@@ -1,19 +1,18 @@
"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 { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
interface Invoice {
id: string;
totalAmount: number;
export interface StatusChartDatum {
status: string;
dueDate: Date | string;
name: string;
count: number;
value: number;
}
interface InvoiceStatusChartProps {
invoices: Invoice[];
data: StatusChartDatum[];
}
const STATUS_COLORS = {
@@ -47,52 +46,26 @@ function StatusTooltip({
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<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" : ""}
</p>
<p className="text-sm">{formatChartCurrency(data.value)}</p>
<p className="font-mono text-sm tabular-nums">
{formatChartCurrency(data.value)}
</p>
</div>
);
}
return null;
}
export function InvoiceStatusChart({ invoices }: 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
export function InvoiceStatusChart({ data }: InvoiceStatusChartProps) {
const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences();
const pieAnimationDuration = Math.round(
600 / (animationSpeedMultiplier || 1),
);
if (chartData.length === 0) {
if (data.length === 0) {
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
@@ -109,11 +82,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
return (
<div className="space-y-4">
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<ResponsiveChart height={192} className="h-48">
<PieChart>
<Pie
data={chartData}
data={data}
cx="50%"
cy="50%"
innerRadius={40}
@@ -124,7 +96,7 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
animationDuration={pieAnimationDuration}
animationEasing="ease-out"
>
{chartData.map((entry, index) => (
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={
@@ -135,12 +107,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
</Pie>
<Tooltip content={<StatusTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
</ResponsiveChart>
{/* Legend */}
<div className="space-y-2">
{chartData.map((item) => (
{data.map((item) => (
<div key={item.status} className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div
@@ -153,8 +123,10 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
<span className="text-sm font-medium">{item.name}</span>
</div>
<div className="text-right">
<p className="text-sm font-medium">{item.count}</p>
<p className="text-muted-foreground text-xs">
<p className="font-mono text-sm font-medium tabular-nums">
{item.count}
</p>
<p className="text-muted-foreground font-mono text-xs tabular-nums">
{formatChartCurrency(item.value)}
</p>
</div>
@@ -3,25 +3,25 @@
import {
Bar,
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { ResponsiveChart } from "~/components/charts/responsive-chart";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface Invoice {
id: string;
totalAmount: number;
issueDate: Date | string;
status: string;
dueDate: Date | string;
export interface MonthlyMetricsChartDatum {
month: string;
monthLabel: string;
totalInvoices: number;
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
}
interface MonthlyMetricsChartProps {
invoices: Invoice[];
data: MonthlyMetricsChartDatum[];
}
function MonthlyMetricsTooltip({
@@ -31,28 +31,30 @@ function MonthlyMetricsTooltip({
}: {
active?: boolean;
payload?: Array<{
payload: {
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
totalInvoices: number;
};
payload: MonthlyMetricsChartDatum;
}>;
label?: string;
}) {
if (active && payload?.length) {
const data = payload[0]!.payload;
const chartDatum = payload[0]!.payload;
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<p className="font-medium">{label}</p>
<div className="space-y-1 text-sm">
<p className="text-primary font-medium">Paid: {data.paidInvoices}</p>
<p className="text-primary/80">Pending: {data.pendingInvoices}</p>
<p className="text-destructive">Overdue: {data.overdueInvoices}</p>
<p className="text-muted-foreground">Draft: {data.draftInvoices}</p>
<p className="text-foreground border-t pt-1 font-medium">
Total: {data.totalInvoices}
<p className="text-primary font-medium font-mono tabular-nums">
Paid: {chartDatum.paidInvoices}
</p>
<p className="text-primary/80 font-mono tabular-nums">
Pending: {chartDatum.pendingInvoices}
</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>
</div>
</div>
@@ -61,78 +63,14 @@ function MonthlyMetricsTooltip({
return null;
}
export function MonthlyMetricsChart({ invoices }: 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
export function MonthlyMetricsChart({ data }: MonthlyMetricsChartProps) {
const { prefersReducedMotion, animationSpeedMultiplier } =
useAnimationPreferences();
const barAnimationDuration = Math.round(
500 / (animationSpeedMultiplier || 1),
);
if (chartData.length === 0) {
if (data.length === 0) {
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
@@ -149,9 +87,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
return (
<div className="space-y-4">
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<ResponsiveChart height={192} className="h-48">
<BarChart data={data}>
<XAxis
dataKey="monthLabel"
axisLine={false}
@@ -161,7 +98,11 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
tick={{
fontSize: 12,
fill: "var(--muted-foreground)",
fontFamily: "var(--font-mono)",
}}
/>
<Tooltip content={<MonthlyMetricsTooltip />} />
<Bar
@@ -202,10 +143,8 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
animationEasing="ease-out"
/>
</BarChart>
</ResponsiveContainer>
</div>
</ResponsiveChart>
{/* Legend */}
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
<div className="flex items-center space-x-2">
<div
@@ -3,11 +3,11 @@
import {
Area,
AreaChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ResponsiveChart } from "~/components/charts/responsive-chart";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
interface RevenueChartProps {
@@ -41,7 +41,10 @@ const CustomTooltip = ({
return (
<div className="bg-card border-border rounded-lg border p-3 shadow-lg">
<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)}
</p>
<p className="text-muted-foreground text-sm">
@@ -84,9 +87,8 @@ export function RevenueChart({ data }: RevenueChartProps) {
}
return (
<div className="h-48 w-full md:h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<ResponsiveChart height={256} className="h-48 md:h-64">
<AreaChart data={chartData}>
<defs>
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop
@@ -110,7 +112,11 @@ export function RevenueChart({ data }: RevenueChartProps) {
<YAxis
axisLine={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}
/>
<Tooltip content={<CustomTooltip />} />
@@ -127,7 +133,6 @@ export function RevenueChart({ data }: RevenueChartProps) {
animationEasing="ease-out"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</ResponsiveChart>
);
}
+23 -5
View File
@@ -1,16 +1,34 @@
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { Suspense } from "react";
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 { AdministrationContent } from "./_components/administration-content";
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 (
<div className="page-enter space-y-6">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Administration"
description="Manage account access and platform administration"
variant="gradient"
/>
<HydrateClient>
@@ -18,6 +36,6 @@ export default async function AdministrationPage() {
<AdministrationContent />
</Suspense>
</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 { Button } from "~/components/ui/button";
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 Link from "next/link";
import {
@@ -43,11 +49,10 @@ export default async function BusinessDetailPage({
};
return (
<div className="space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
description="View business details and information"
variant="gradient"
>
<Button asChild variant="outline" className="shadow-sm">
<Link href="/dashboard/entities?tab=businesses">
@@ -61,9 +66,9 @@ export default async function BusinessDetailPage({
<span>Edit Business</span>
</Link>
</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 */}
<div className="lg:col-span-2">
<Card className="bg-card border-border border">
@@ -265,7 +270,7 @@ export default async function BusinessDetailPage({
</div>
{/* Settings & Actions Card */}
<div className="space-y-6">
<div className={cn("flex flex-col", dashboardGapClass)}>
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -323,6 +328,6 @@ export default async function BusinessDetailPage({
</Card>
</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 { Button } from "~/components/ui/button";
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 {
Edit,
@@ -57,11 +63,10 @@ export default async function ClientDetailPage({
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
return (
<div className="space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title={client.name}
description="View client details and information"
variant="gradient"
>
<Button asChild variant="outline" className="shadow-sm">
<Link href="/dashboard/entities?tab=clients">
@@ -75,9 +80,9 @@ export default async function ClientDetailPage({
<span>Edit Client</span>
</Link>
</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 */}
<div className="lg:col-span-2">
<Card className="bg-card border-border border">
@@ -173,7 +178,7 @@ export default async function ClientDetailPage({
</div>
{/* Stats Card */}
<div className="space-y-6">
<div className={cn("flex flex-col", dashboardGapClass)}>
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -275,6 +280,6 @@ export default async function ClientDetailPage({
)}
</div>
</div>
</div>
</DashboardPage>
);
}
@@ -3,15 +3,32 @@
import { Plus } from "lucide-react";
import Link from "next/link";
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { ClientsTable } from "../../clients/_components/clients-table";
import { BusinessesTable } from "../../businesses/_components/businesses-table";
import { ClientsDataTable } from "../../clients/_components/clients-data-table";
import { BusinessesDataTable } from "../../businesses/_components/businesses-data-table";
import type { RouterOutputs } from "~/trpc/react";
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 searchParams = useSearchParams();
const tab: EntityTab =
@@ -27,11 +44,10 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
const addLabel = tab === "clients" ? "Add client" : "Add business";
return (
<div className="space-y-6">
<PageHeader
<>
<DashboardPageHeader
title="Entities"
description="Clients you bill and businesses you send from"
variant="gradient"
>
<Button asChild variant="default" className="hover-lift shadow-md">
<Link href={addHref}>
@@ -39,22 +55,24 @@ export function EntitiesView({ initialTab }: { initialTab: EntityTab }) {
<span>{addLabel}</span>
</Link>
</Button>
</PageHeader>
</DashboardPageHeader>
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="clients">Clients</TabsTrigger>
<TabsTrigger value="businesses">Businesses</TabsTrigger>
</TabsList>
<PageTabs value={tab} onValueChange={handleTabChange}>
<PageTabsList>
<PageTabsTrigger value="clients">Clients</PageTabsTrigger>
<PageTabsTrigger value="businesses">Businesses</PageTabsTrigger>
</PageTabsList>
<TabsContent value="clients" className="mt-6">
<ClientsTable />
</TabsContent>
<PageTabsContent value="clients">
{tab === "clients" ? <ClientsDataTable clients={clients} /> : null}
</PageTabsContent>
<TabsContent value="businesses" className="mt-6">
<BusinessesTable />
</TabsContent>
</Tabs>
</div>
<PageTabsContent value="businesses">
{tab === "businesses" ? (
<BusinessesDataTable businesses={businesses} />
) : null}
</PageTabsContent>
</PageTabs>
</>
);
}
+13 -12
View File
@@ -1,6 +1,5 @@
import { Suspense } from "react";
import { DataTableSkeleton } from "~/components/data/data-table";
import { api, HydrateClient } from "~/trpc/server";
import { api } from "~/trpc/server";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { EntitiesView } from "./_components/entities-view";
export default async function EntitiesPage({
@@ -11,16 +10,18 @@ export default async function EntitiesPage({
const params = await searchParams;
const initialTab = params.tab === "businesses" ? "businesses" : "clients";
void api.clients.getAll.prefetch();
void api.businesses.getAll.prefetch();
const [clients, businesses] = await Promise.all([
api.clients.getAll(),
api.businesses.getAll(),
]);
return (
<div className="page-enter space-y-6">
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={5} rows={8} />}>
<EntitiesView initialTab={initialTab} />
</Suspense>
</HydrateClient>
</div>
<DashboardPage>
<EntitiesView
initialTab={initialTab}
clients={clients}
businesses={businesses}
/>
</DashboardPage>
);
}
+7 -8
View File
@@ -2,7 +2,8 @@
import { useState } from "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 { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
@@ -145,11 +146,10 @@ export default function ExpensesPage() {
.reduce((s, e) => s + e.amount, 0);
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Expenses"
description="Track billable and non-billable expenses"
variant="gradient"
>
<Button
onClick={handleOpen}
@@ -158,10 +158,9 @@ export default function ExpensesPage() {
>
<Plus className="mr-2 h-5 w-5" /> Add Expense
</Button>
</PageHeader>
</DashboardPageHeader>
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className={dashboardStatGridClass}>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
@@ -476,6 +475,6 @@ export default function ExpensesPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardPage>
);
}
@@ -1,25 +1,27 @@
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
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() {
return (
<div className="space-y-6 pb-24">
{/* Header */}
<PageHeader
<DashboardPage className="pb-24">
<DashboardPageHeader
title="Loading..."
description="View and manage invoice information"
variant="gradient"
>
<Skeleton className="h-10 w-10 sm:w-32" />
<Skeleton className="h-10 w-24" />
</PageHeader>
</DashboardPageHeader>
{/* Content */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left Column */}
<div className="space-y-6 lg:col-span-2">
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
{/* Invoice Header Skeleton */}
<Card>
<CardContent className="p-4 sm:p-6">
@@ -155,7 +157,7 @@ export function InvoiceDetailsSkeleton() {
</div>
{/* Right Column - Actions */}
<div className="space-y-6">
<div className={cn("flex flex-col", dashboardGapClass)}>
<Card className="lg:sticky lg:top-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -172,6 +174,6 @@ export function InvoiceDetailsSkeleton() {
</Card>
</div>
</div>
</div>
</DashboardPage>
);
}
@@ -25,7 +25,7 @@ export function PDFDownloadButton({
{ id: invoiceId },
{ enabled: false },
);
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, {
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery(undefined, {
staleTime: 60_000,
});
@@ -59,11 +59,11 @@ export function PDFDownloadButton({
};
await generateInvoicePDF(pdfData, {
pdfTemplate: platformTheme?.pdfTemplate,
pdfAccentColor: platformTheme?.pdfAccentColor,
pdfFooterText: platformTheme?.pdfFooterText,
pdfShowLogo: platformTheme?.pdfShowLogo,
pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers,
pdfTemplate: pdfSettings?.pdfTemplate,
pdfAccentColor: pdfSettings?.pdfAccentColor,
pdfFooterText: pdfSettings?.pdfFooterText,
pdfShowLogo: pdfSettings?.pdfShowLogo,
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers,
});
toast.success("PDF downloaded successfully");
} catch (error) {
+14 -9
View File
@@ -24,7 +24,13 @@ import { notFound, useParams, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { toast } from "sonner";
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 { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -224,11 +230,10 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
};
return (
<div className="page-enter space-y-6 pb-24">
<PageHeader
<DashboardPage className="pb-24">
<DashboardPageHeader
title="Invoice Details"
description="View and manage invoice information"
variant="gradient"
>
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
<Button asChild variant="default" className="hover-lift">
@@ -237,11 +242,11 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
Edit
</Link>
</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 */}
<div className="space-y-6 lg:col-span-2">
<div className={cn("flex flex-col lg:col-span-2", dashboardGapClass)}>
{/* Invoice Header */}
<Card>
<CardContent className="p-4 sm:p-6">
@@ -531,7 +536,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</div>
{/* Right Column - Actions */}
<div className="space-y-6">
<div className={cn("flex flex-col", dashboardGapClass)}>
{storedStatus === "draft" && (
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
)}
@@ -833,7 +838,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardPage>
);
}
+43 -35
View File
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "~/components/ui/button";
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 { Separator } from "~/components/ui/separator";
import { Alert, AlertDescription } from "~/components/ui/alert";
@@ -17,7 +16,20 @@ import {
DialogHeader,
DialogTitle,
} 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 { EmailComposer } from "~/components/forms/email-composer";
import { EmailPreview } from "~/components/forms/email-preview";
@@ -36,21 +48,20 @@ import {
function SendEmailPageSkeleton() {
return (
<div className="space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title="Loading..."
description="Loading invoice email"
variant="gradient"
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
<div className={cn("lg:col-span-2", dashboardGapClass, "flex flex-col")}>
<div className="bg-muted h-96 animate-pulse" />
</div>
<div className="space-y-6">
<div className={cn(dashboardGapClass, "flex flex-col")}>
<div className="bg-muted h-64 animate-pulse" />
</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 canSend =
@@ -292,18 +303,18 @@ export default function SendEmailPage() {
if (!invoice) {
return (
<div className="page-enter space-y-6">
<DashboardPage>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>Invoice not found.</AlertDescription>
</Alert>
</div>
</DashboardPage>
);
}
return (
<div className="page-enter space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title={`Send Invoice ${invoice.invoiceNumber}`}
description={`Compose and send invoice email to ${invoice.client?.name ?? "client"}${new Intl.DateTimeFormat(
"en-US",
@@ -313,7 +324,6 @@ export default function SendEmailPage() {
day: "numeric",
},
).format(new Date())}`}
variant="gradient"
>
<Button
variant="outline"
@@ -322,7 +332,7 @@ export default function SendEmailPage() {
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Invoice
</Button>
</PageHeader>
</DashboardPageHeader>
{/* Warning for missing email */}
{(!toEmail || toEmail.trim() === "") && (
@@ -336,23 +346,22 @@ export default function SendEmailPage() {
)}
{/* 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">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="compose" className="flex items-center gap-2">
<PageTabs value={activeTab} onValueChange={setActiveTab}>
<PageTabsList>
<PageTabsTrigger value="compose" className="gap-2">
<Edit3 className="h-4 w-4" />
Compose
</TabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2">
</PageTabsTrigger>
<PageTabsTrigger value="preview" className="gap-2">
<Eye className="h-4 w-4" />
Preview
</TabsTrigger>
</TabsList>
</PageTabsTrigger>
</PageTabsList>
<div className="mt-6">
<TabsContent value="compose" className="space-y-6">
<Card>
<PageTabsContent value="compose">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
@@ -387,10 +396,10 @@ export default function SendEmailPage() {
)}
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
<TabsContent value="preview" className="space-y-6">
<Card>
<PageTabsContent value="preview">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
@@ -413,13 +422,12 @@ export default function SendEmailPage() {
</div>
</CardContent>
</Card>
</TabsContent>
</div>
</Tabs>
</PageTabsContent>
</PageTabs>
</div>
{/* Sidebar */}
<div className="space-y-6">
<div className={cn(dashboardGapClass, "flex flex-col")}>
{/* Invoice Summary */}
<Card>
<CardHeader>
@@ -644,6 +652,6 @@ export default function SendEmailPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardPage>
);
}
+8 -7
View File
@@ -10,7 +10,9 @@ import {
} from "lucide-react";
import Link from "next/link";
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 { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -19,7 +21,7 @@ import { HydrateClient } from "~/trpc/server";
// File Upload Instructions Component
function FormatInstructions() {
return (
<div className="grid gap-6 lg:grid-cols-2">
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
{/* Required Format */}
<Card className="bg-card border-border border">
<CardHeader>
@@ -203,11 +205,10 @@ function FileFormatHelp() {
export default async function ImportPage() {
return (
<div className="space-y-8">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Import Time Entries"
description="Upload CSV files to create invoices from your time tracking data"
variant="gradient"
>
<Link href="/dashboard/invoices">
<Button variant="outline" size="lg">
@@ -215,7 +216,7 @@ export default async function ImportPage() {
Back to Invoices
</Button>
</Link>
</PageHeader>
</DashboardPageHeader>
<HydrateClient>
{/* Main CSV Import Component */}
@@ -230,6 +231,6 @@ export default async function ImportPage() {
{/* Important Notes */}
<ImportantNotes />
</HydrateClient>
</div>
</DashboardPage>
);
}
+6 -6
View File
@@ -2,7 +2,8 @@ import Link from "next/link";
import { Suspense } from "react";
import { api, HydrateClient } from "~/trpc/server";
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 { InvoicesDataTable } from "./_components/invoices-data-table";
import { DataTableSkeleton } from "~/components/data/data-table";
@@ -16,11 +17,10 @@ async function InvoicesTable() {
export default async function InvoicesPage() {
return (
<div className="page-enter space-y-6">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Invoices"
description="Manage your invoices and track payments"
variant="gradient"
>
<Button asChild variant="outline" className="hover-lift shadow-sm">
<Link href="/dashboard/invoices/import">
@@ -40,13 +40,13 @@ export default async function InvoicesPage() {
<span>Create Invoice</span>
</Link>
</Button>
</PageHeader>
</DashboardPageHeader>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
<InvoicesTable />
</Suspense>
</HydrateClient>
</div>
</DashboardPage>
);
}
@@ -13,7 +13,8 @@ import {
import { useState } from "react";
import { useRouter } from "next/navigation";
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 { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
@@ -358,17 +359,16 @@ export default function RecurringInvoicesPage() {
const isSubmitting = create.isPending || update.isPending;
return (
<div className="page-enter space-y-6 pb-24">
<PageHeader
<DashboardPage className="pb-24">
<DashboardPageHeader
title="Recurring Invoices"
description="Schedule automatic invoice generation"
variant="gradient"
>
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
<Plus className="mr-2 h-4 w-4" />
New recurring
</Button>
</PageHeader>
</DashboardPageHeader>
{isLoading ? (
<div className="flex h-48 items-center justify-center">
@@ -529,6 +529,6 @@ export default function RecurringInvoicesPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardPage>
);
}
+24 -18
View File
@@ -2,7 +2,14 @@
import { useState } from "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 { Card, CardContent } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
@@ -18,7 +25,7 @@ import {
DialogHeader,
DialogTitle,
} 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 { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
@@ -187,25 +194,24 @@ export default function TemplatesPage() {
const termsTemplates = templates.filter((t) => t.type === "terms");
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader
<DashboardPage className="pb-6">
<DashboardPageHeader
title="Invoice Templates"
description="Reusable notes and payment terms for your invoices"
variant="gradient"
/>
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="notes">
<PageTabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
<PageTabsList>
<PageTabsTrigger value="notes">
<FileText className="mr-1.5 h-4 w-4" /> Notes (
{notesTemplates.length})
</TabsTrigger>
<TabsTrigger value="terms">
</PageTabsTrigger>
<PageTabsTrigger value="terms">
<FileText className="mr-1.5 h-4 w-4" /> Terms (
{termsTemplates.length})
</TabsTrigger>
</TabsList>
<TabsContent value="notes" className="mt-4">
</PageTabsTrigger>
</PageTabsList>
<PageTabsContent value="notes">
<TemplateList
items={notesTemplates}
type="notes"
@@ -214,8 +220,8 @@ export default function TemplatesPage() {
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent>
<TabsContent value="terms" className="mt-4">
</PageTabsContent>
<PageTabsContent value="terms">
<TemplateList
items={termsTemplates}
type="terms"
@@ -224,8 +230,8 @@ export default function TemplatesPage() {
onEdit={handleEdit}
onDelete={setDeleteId}
/>
</TabsContent>
</Tabs>
</PageTabsContent>
</PageTabs>
{/* Create/Edit dialog */}
<Dialog open={open} onOpenChange={setOpen}>
@@ -320,6 +326,6 @@ export default function TemplatesPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardPage>
);
}
+18 -1
View File
@@ -1,7 +1,11 @@
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { AppProviders } from "~/components/providers/app-providers";
import { DashboardShell } from "~/components/layout/dashboard-shell";
import { DashboardUserProvider } from "~/components/layout/dashboard-user-context";
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
export const dynamic = "force-dynamic";
@@ -16,9 +20,22 @@ export default async function DashboardLayout({
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 (
<AppProviders>
<DashboardShell>{children}</DashboardShell>
<DashboardUserProvider isAdmin={isAdmin} needsOnboarding={needsOnboarding}>
<DashboardShell>{children}</DashboardShell>
</DashboardUserProvider>
</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,
} from "lucide-react";
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 { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Skeleton } from "~/components/ui/skeleton";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { getOptionalServerSessionFromHeaders } from "~/lib/auth-server";
import { HydrateClient, api } from "~/trpc/server";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { RevenueChart, InvoiceStatusChart, MonthlyMetricsChart } from "~/app/dashboard/_components/charts-client";
import { AnimatedStatsCard } from "~/app/dashboard/_components/animated-stats-card";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/server";
import type { DashboardStats, RecentInvoice } from "./types";
// Hero section with clean mono design
// Enhanced stats cards with better visuals
function DashboardStats({ stats }: { stats: DashboardStats }) {
// TODO: Import RouterOutput type
const formatTrend = (value: number, isCount = false) => {
if (isCount) {
return value > 0 ? `+${value}` : value.toString();
@@ -44,42 +54,42 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
change: formatTrend(stats.revenueChange),
trend: stats.revenueChange >= 0 ? ("up" as const) : ("down" 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 })}`,
numericValue: stats.pendingAmount,
isCurrency: true,
change: "0%", // TODO: Calculate pending change if needed
change: "0%",
trend: "neutral" as const,
iconName: "Clock" as const,
description: "Invoices awaiting payment",
description: "Awaiting payment",
},
{
title: "Active Clients",
title: "Clients",
value: stats.totalClients.toString(),
numericValue: stats.totalClients,
isCurrency: false,
change: "0", // TODO: Calculate client change if needed
change: "0",
trend: "neutral" as const,
iconName: "Users" as const,
description: "Total registered clients",
description: "Active clients",
},
{
title: "Overdue Invoices",
title: "Overdue",
value: stats.overdueCount.toString(),
numericValue: stats.overdueCount,
isCurrency: false,
change: "0", // TODO: Calculate overdue change if needed
change: "0",
trend: "neutral" as const,
iconName: "TrendingDown" as const,
description: "Invoices past due date",
description: "Past due date",
},
];
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) => (
<AnimatedStatsCard
key={stat.title}
@@ -98,21 +108,15 @@ function DashboardStats({ stats }: { stats: DashboardStats }) {
);
}
// Charts section
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();
function ChartsSection({ stats }: { stats: DashboardStats }) {
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Revenue Trend Chart */}
<DashboardGrid className="lg:grid-cols-2">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Revenue Over Time
<CardTitle>
<DashboardCardTitle icon={BarChart3}>
Revenue over time
</DashboardCardTitle>
</CardTitle>
</CardHeader>
<CardContent>
@@ -120,55 +124,54 @@ async function ChartsSection({ stats }: { stats: DashboardStats }) {
</CardContent>
</Card>
{/* Invoice Status Breakdown */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Invoice Status
<CardTitle>
<DashboardCardTitle icon={Activity}>
Invoice status
</DashboardCardTitle>
</CardTitle>
</CardHeader>
<CardContent>
<InvoiceStatusChart invoices={invoices} />
<InvoiceStatusChart data={stats.statusChartData} />
</CardContent>
</Card>
{/* Monthly Metrics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Monthly Metrics
<CardTitle>
<DashboardCardTitle icon={Calendar}>
Monthly metrics
</DashboardCardTitle>
</CardTitle>
</CardHeader>
<CardContent>
<MonthlyMetricsChart invoices={invoices} />
<MonthlyMetricsChart data={stats.monthlyMetricsChartData} />
</CardContent>
</Card>
</div>
</DashboardGrid>
);
}
// Enhanced Quick Actions
function QuickActions() {
const actions = [
{
title: "Create Invoice",
title: "Create invoice",
description: "Start a new invoice for a client",
href: "/dashboard/invoices/new",
icon: FileText,
featured: true,
},
{
title: "Add Client",
description: "Register a new client",
title: "Add client",
description: "Register someone you bill",
href: "/dashboard/clients/new",
icon: Users,
featured: false,
},
{
title: "View All Invoices",
description: "Manage your invoice pipeline",
title: "View invoices",
description: "Browse your full pipeline",
href: "/dashboard/invoices",
icon: BarChart3,
featured: false,
@@ -178,27 +181,36 @@ function QuickActions() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
Quick Actions
<CardTitle>
<DashboardCardTitle icon={Plus}>Quick actions</DashboardCardTitle>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-2">
{actions.map((action) => {
const Icon = action.icon;
return (
<Link
key={action.title}
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
? "border-foreground/20 bg-muted/50 hover:bg-muted"
: "border-border bg-background hover:bg-muted/50"
}`}
? "border-primary/20 bg-primary/5 hover:bg-primary/10"
: "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">
<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">
{action.description}
</p>
@@ -211,204 +223,168 @@ function QuickActions() {
);
}
// Current work section with enhanced design
async function CurrentWork() {
const invoices = await api.invoices.getAll();
const draftInvoices = invoices.filter(
(invoice) =>
getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
) === "draft",
);
const currentInvoice = draftInvoices[0];
if (!currentInvoice) {
function CurrentWork({
currentDraft,
}: {
currentDraft: DashboardStats["currentDraft"];
}) {
if (!currentDraft) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Current Work
<CardTitle>
<DashboardCardTitle icon={Activity}>
Current work
</DashboardCardTitle>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-8 text-center">
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<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>
<CardContent className="flex flex-col items-center py-8 text-center">
<div className="bg-muted mb-4 rounded-2xl p-3">
<FileText className="text-muted-foreground h-6 w-6" />
</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>
</Card>
);
}
const totalHours =
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
const totalHours = currentDraft.totalHours;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Current Work
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>
<DashboardCardTitle icon={Activity}>Current work</DashboardCardTitle>
</CardTitle>
<Badge variant="secondary">In Progress</Badge>
<Badge variant="secondary">Draft</Badge>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-lg font-semibold break-words">
#{currentInvoice.invoiceNumber}
</h3>
<span className="text-primary text-2xl font-bold">
${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>
<CardContent className="space-y-5">
<div className="space-y-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="font-medium">#{currentDraft.invoiceNumber}</p>
<p className="text-muted-foreground text-sm">
{currentDraft.client?.name}
</p>
</div>
<p className="font-mono text-xl font-semibold tabular-nums">
${currentDraft.totalAmount.toFixed(2)}
</p>
</div>
<p className="text-muted-foreground font-mono text-xs tabular-nums">
{totalHours.toFixed(1)} hours logged
</p>
</div>
<div className="flex gap-2">
<Button
asChild
variant="outline"
size="sm"
className="hover-lift flex-1"
>
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
<Eye className="mr-2 h-4 w-4" />
View
</Link>
</Button>
<Button asChild size="sm" className="hover-lift flex-1">
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Continue
</Link>
</Button>
</div>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm" className="flex-1">
<Link href={`/dashboard/invoices/${currentDraft.id}`}>
<Eye className="mr-2 h-4 w-4" />
View
</Link>
</Button>
<Button asChild size="sm" className="flex-1">
<Link href={`/dashboard/invoices/${currentDraft.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Continue
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
// Enhanced recent activity
async function RecentActivity({
function RecentActivity({
recentInvoices,
}: {
recentInvoices: RecentInvoice[];
}) {
// Use passed recentInvoices instead of fetching all
const getStatusStyle = (status: string) => {
const getStatusVariant = (status: string) => {
switch (status) {
case "paid":
return {
backgroundColor: "oklch(var(--chart-2) / 0.1)",
borderColor: "oklch(var(--chart-2) / 0.3)",
color: "oklch(var(--chart-2))",
};
return "default" as const;
case "sent":
return {
backgroundColor: "oklch(var(--chart-1) / 0.1)",
borderColor: "oklch(var(--chart-1) / 0.3)",
color: "oklch(var(--chart-1))",
};
return "secondary" as const;
case "overdue":
return {
backgroundColor: "oklch(var(--chart-3) / 0.1)",
borderColor: "oklch(var(--chart-3) / 0.3)",
color: "oklch(var(--chart-3))",
};
return "destructive" as const;
default:
return {
backgroundColor: "hsl(var(--muted))",
borderColor: "hsl(var(--border))",
color: "hsl(var(--muted-foreground))",
};
return "outline" as const;
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Recent Activity
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>
<DashboardCardTitle icon={Calendar}>
Recent activity
</DashboardCardTitle>
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<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" />
</Link>
</Button>
</CardHeader>
<CardContent>
{recentInvoices.length === 0 ? (
<div className="py-8 text-center">
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">No invoices yet</h3>
<p className="text-muted-foreground mb-4">
Create your first invoice to get started
</p>
<Button asChild variant="outline" className="border-foreground/20">
<div className="flex flex-col items-center py-8 text-center">
<div className="bg-muted mb-4 rounded-2xl p-3">
<FileText className="text-muted-foreground h-6 w-6" />
</div>
<p className="font-medium">No invoices yet</p>
<CardDescription className="mt-1 max-w-xs">
Your latest invoices will show up here.
</CardDescription>
<Button asChild variant="outline" className="mt-5">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create Your First Invoice
Create invoice
</Link>
</Button>
</div>
) : (
<div className="space-y-3">
{recentInvoices.map((invoice, _index) => (
<div className="space-y-2">
{recentInvoices.map((invoice) => (
<Link
key={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="flex items-start gap-3">
<div className="bg-muted flex-shrink-0 rounded-lg p-2">
<FileText className="text-muted-foreground h-4 w-4" />
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate font-medium">
#{invoice.invoiceNumber}
</p>
<p className="text-muted-foreground truncate text-sm">
{invoice.client?.name}
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<Badge style={getStatusStyle(invoice.status)}>
{invoice.status}
</Badge>
<span className="text-primary font-semibold">
${invoice.totalAmount.toFixed(2)}
</span>
</div>
</div>
<p className="text-muted-foreground text-xs">
{new Date(invoice.issueDate).toLocaleDateString()}
</p>
</div>
<div className="bg-muted rounded-xl p-2">
<FileText className="text-muted-foreground h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-medium">
#{invoice.invoiceNumber}
</p>
<span className="shrink-0 font-mono text-sm font-medium tabular-nums">
${invoice.totalAmount.toFixed(2)}
</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<p className="text-muted-foreground truncate text-xs">
{invoice.client?.name}
</p>
<Badge
variant={getStatusVariant(invoice.status)}
className="shrink-0 text-[10px]"
>
{invoice.status}
</Badge>
</div>
</div>
</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() {
const session = await getOptionalServerSessionFromHeaders();
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
// Fetch stats centrally
const stats = await api.dashboard.getStats();
void api.timeEntries.getRunning.prefetch();
return (
<div className="page-enter space-y-6">
<DashboardPageLayout>
<DashboardPageHeader
title={`Welcome back, ${firstName}!`}
description="Here's what's happening with your business today"
title={`Welcome back, ${firstName}`}
description="A snapshot of your invoices, revenue, and work in progress."
/>
<HydrateClient>
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats stats={stats} />
</Suspense>
</HydrateClient>
<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>
<DashboardStats stats={stats} />
<ChartsSection stats={stats} />
<DashboardGrid className="lg:grid-cols-2">
<div className={cn(dashboardGridClass)}>
<CurrentWork currentDraft={stats.currentDraft} />
<QuickActions />
</div>
<HydrateClient>
<Suspense fallback={<CardSkeleton />}>
<RecentActivity recentInvoices={stats.recentInvoices} />
</Suspense>
</HydrateClient>
</div>
</div>
<RecentActivity recentInvoices={stats.recentInvoices} />
</DashboardGrid>
</DashboardPageLayout>
);
}
+30 -26
View File
@@ -2,7 +2,8 @@
import { useMemo, useState } from "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 { StatusBadge } from "~/components/data/status-badge";
import { Button } from "~/components/ui/button";
@@ -14,7 +15,12 @@ import {
SelectValue,
} from "~/components/ui/select";
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 { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import type { StoredInvoiceStatus } from "~/types/invoice";
@@ -308,42 +314,40 @@ export default function ReportsPage() {
if (isLoading) {
return (
<div className="page-enter space-y-6">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Reports"
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) => (
<div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />
))}
</div>
</div>
</DashboardPage>
);
}
return (
<div className="page-enter space-y-6 pb-6">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Reports"
description="Revenue and tax analytics"
variant="gradient"
/>
<Tabs defaultValue="overview">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="overview">
<TrendingUp className="mr-1.5 h-4 w-4" /> Overview
</TabsTrigger>
<TabsTrigger value="tax">
<FileText className="mr-1.5 h-4 w-4" /> Tax Summary
</TabsTrigger>
</TabsList>
<PageTabs defaultValue="overview">
<PageTabsList>
<PageTabsTrigger value="overview" className="gap-1.5">
<TrendingUp className="h-4 w-4" /> Overview
</PageTabsTrigger>
<PageTabsTrigger value="tax" className="gap-1.5">
<FileText className="h-4 w-4" /> Tax Summary
</PageTabsTrigger>
</PageTabsList>
{/* ── OVERVIEW TAB ── */}
<TabsContent value="overview" className="mt-4 space-y-6">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<PageTabsContent value="overview">
<div className={dashboardStatGridClass}>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
@@ -630,10 +634,10 @@ export default function ReportsPage() {
</CardContent>
</Card>
)}
</TabsContent>
</PageTabsContent>
{/* ── 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 gap-3">
<span className="text-sm font-medium">Tax Year</span>
@@ -840,8 +844,8 @@ export default function ReportsPage() {
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</PageTabsContent>
</PageTabs>
</DashboardPage>
);
}
@@ -12,16 +12,13 @@ import {
FileUp,
Info,
Key,
Monitor,
Palette,
Shield,
Upload,
User,
Users,
Link as LinkIcon,
Monitor,
PanelLeft,
Paintbrush,
Type,
} from "lucide-react";
import dynamic from "next/dynamic";
import { authClient } from "~/lib/auth-client";
@@ -70,11 +67,17 @@ import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
import { env } from "~/env";
import { Badge } from "~/components/ui/badge";
import { Switch } from "~/components/ui/switch";
import { Slider } from "~/components/ui/slider";
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 {
Select,
SelectContent,
@@ -83,19 +86,8 @@ import {
SelectValue,
} from "~/components/ui/select";
import { useAppearance } from "~/components/providers/appearance-provider";
import {
bodyFontPreferences,
brand,
colorModes,
colorThemes,
type ColorTheme,
headingFontPreferences,
interfaceThemes,
radiusPreferences,
sidebarStyles,
themePresets,
type InterfaceTheme,
} from "~/lib/branding";
import { brand, colorModes } from "~/lib/branding";
import type { PdfTemplate } from "~/lib/appearance";
import { ApiAccessSettings } from "./api-access-settings";
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) {
return /^#[0-9A-Fa-f]{6}$/.test(value);
}
@@ -197,43 +123,28 @@ export function SettingsContent() {
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLinking, setIsLinking] = useState(false);
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const {
interfaceTheme,
bodyFontPreference,
headingFontPreference,
radiusPreference,
sidebarStyle,
colorMode,
colorTheme,
customColor,
brandName,
brandTagline,
brandLogoText,
brandIcon,
pdfTemplate,
pdfAccentColor,
pdfFooterText,
pdfShowLogo,
pdfShowPageNumbers,
updateAppearance,
updateAppearanceDebounced,
isUpdating: appearanceUpdating,
} = useAppearance();
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 { colorMode, updateAppearance, isUpdating: appearanceUpdating } =
useAppearance();
const utils = api.useUtils();
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery();
const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({
onSuccess: async () => {
await utils.settings.getPdfSettings.invalidate();
toast.success("Invoice PDF settings updated");
},
onError: (error: { message: string }) => {
toast.error(`Failed to update PDF settings: ${error.message}`);
},
});
const savePdfSettings = (patch: {
pdfTemplate?: PdfTemplate;
pdfAccentColor?: string;
pdfFooterText?: string;
pdfShowLogo?: boolean;
pdfShowPageNumbers?: boolean;
}) => {
updatePdfSettingsMutation.mutate(patch);
};
const handleLinkAuthentik = async () => {
@@ -320,8 +231,9 @@ export function SettingsContent() {
const importDataMutation = api.settings.importData.useMutation({
onSuccess: (result) => {
const { imported } = result;
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("");
setIsImportDialogOpen(false);
@@ -494,16 +406,16 @@ export function SettingsContent() {
];
return (
<Tabs defaultValue="general">
<TabsList className="bg-muted/50 grid w-full grid-cols-4">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="preferences">Preferences</TabsTrigger>
<TabsTrigger value="data">Data</TabsTrigger>
<TabsTrigger value="api">API</TabsTrigger>
</TabsList>
<PageTabs defaultValue="general">
<PageTabsList>
<PageTabsTrigger value="general">General</PageTabsTrigger>
<PageTabsTrigger value="preferences">Preferences</PageTabsTrigger>
<PageTabsTrigger value="data">Data</PageTabsTrigger>
<PageTabsTrigger value="api">API</PageTabsTrigger>
</PageTabsList>
<TabsContent value="general" className="space-y-8">
<div className="grid gap-6 lg:grid-cols-2">
<PageTabsContent value="general">
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
{/* Profile Section */}
<Card className="form-section bg-card border-border border">
<CardHeader>
@@ -724,9 +636,9 @@ export function SettingsContent() {
</Button>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
<TabsContent value="preferences" className="space-y-8">
<PageTabsContent value="preferences">
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
@@ -734,448 +646,49 @@ export function SettingsContent() {
Appearance
</CardTitle>
<CardDescription>
Select the app skin, color mode, accent, and font stack.
Choose light, dark, or match your system setting.
</CardDescription>
</CardHeader>
{!isAdmin ? (
<CardContent>
<p className="text-muted-foreground text-sm">
Platform appearance and branding are managed by an
administrator.
<CardContent className="space-y-4">
<div className="max-w-sm 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>
</CardContent>
) : (
<CardContent className="space-y-8">
<section className="space-y-4">
<div>
<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>
)}
</div>
{appearanceUpdating && (
<p className="text-muted-foreground text-xs">Saving...</p>
)}
</CardContent>
</Card>
{isAdmin && (
@@ -1191,7 +704,12 @@ export function SettingsContent() {
</CardDescription>
</CardHeader>
<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="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
<div className="space-y-2">
@@ -1200,12 +718,13 @@ export function SettingsContent() {
PDF Template
</Label>
<Select
value={pdfTemplate}
value={pdfSettings?.pdfTemplate ?? "classic"}
onValueChange={(value) =>
updateAppearance({
pdfTemplate: value as typeof pdfTemplate,
savePdfSettings({
pdfTemplate: value as PdfTemplate,
})
}
disabled={updatePdfSettingsMutation.isPending}
>
<SelectTrigger>
<SelectValue />
@@ -1224,13 +743,11 @@ export function SettingsContent() {
<div className="space-y-2">
<InputColor
label="PDF Accent"
value={pdfAccentColor}
value={pdfSettings?.pdfAccentColor ?? "#111827"}
onBlur={() => undefined}
onChange={(value) => {
if (isFullHexColor(value)) {
updateAppearance({
pdfAccentColor: value,
});
savePdfSettings({ pdfAccentColor: value });
}
}}
className="mt-0"
@@ -1241,12 +758,11 @@ export function SettingsContent() {
<div className="space-y-2">
<Label>Footer Text</Label>
<Input
value={pdfFooterText}
value={pdfSettings?.pdfFooterText ?? ""}
onChange={(event) =>
updateAppearanceDebounced({
pdfFooterText: event.target.value,
})
savePdfSettings({ pdfFooterText: event.target.value })
}
disabled={updatePdfSettingsMutation.isPending}
/>
</div>
@@ -1259,10 +775,11 @@ export function SettingsContent() {
</p>
</div>
<Switch
checked={pdfShowLogo}
checked={pdfSettings?.pdfShowLogo ?? true}
onCheckedChange={(checked) =>
updateAppearance({ pdfShowLogo: Boolean(checked) })
savePdfSettings({ pdfShowLogo: Boolean(checked) })
}
disabled={updatePdfSettingsMutation.isPending}
aria-label="Toggle PDF logo"
/>
</div>
@@ -1275,12 +792,13 @@ export function SettingsContent() {
</p>
</div>
<Switch
checked={pdfShowPageNumbers}
checked={pdfSettings?.pdfShowPageNumbers ?? true}
onCheckedChange={(checked) =>
updateAppearance({
savePdfSettings({
pdfShowPageNumbers: Boolean(checked),
})
}
disabled={updatePdfSettingsMutation.isPending}
aria-label="Toggle PDF page numbers"
/>
</div>
@@ -1288,13 +806,14 @@ export function SettingsContent() {
</div>
<PdfPreviewFrame
businessName={brandName}
businessName={brand.name}
settings={{
pdfTemplate,
pdfAccentColor,
pdfFooterText,
pdfShowLogo,
pdfShowPageNumbers,
pdfTemplate: pdfSettings?.pdfTemplate ?? "classic",
pdfAccentColor: pdfSettings?.pdfAccentColor ?? "#111827",
pdfFooterText:
pdfSettings?.pdfFooterText ?? "Professional Invoicing",
pdfShowLogo: pdfSettings?.pdfShowLogo ?? true,
pdfShowPageNumbers: pdfSettings?.pdfShowPageNumbers ?? true,
}}
/>
</div>
@@ -1397,9 +916,9 @@ export function SettingsContent() {
</form>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
<TabsContent value="data" className="space-y-8">
<PageTabsContent value="data">
{/* Data Overview */}
<Card className="form-section bg-card border-border border">
<CardHeader>
@@ -1621,7 +1140,7 @@ export function SettingsContent() {
</Card>
{/* Delete Account (Danger Zone) */}
<Card className="border-destructive/50 bg-destructive/5 border">
<Card className="bg-card border-destructive/50 border">
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
@@ -1672,11 +1191,11 @@ export function SettingsContent() {
</AlertDialog>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
<TabsContent value="api" className="space-y-8">
<PageTabsContent value="api">
<ApiAccessSettings />
</TabsContent>
</Tabs>
</PageTabsContent>
</PageTabs>
);
}
+5 -5
View File
@@ -1,16 +1,16 @@
import { Suspense } from "react";
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 { SettingsContent } from "./_components/settings-content";
export default async function SettingsPage() {
return (
<div className="page-enter space-y-6">
<PageHeader
<DashboardPage>
<DashboardPageHeader
title="Settings"
description="Manage your account preferences and data"
variant="gradient"
/>
<HydrateClient>
@@ -18,6 +18,6 @@ export default async function SettingsPage() {
<SettingsContent />
</Suspense>
</HydrateClient>
</div>
</DashboardPage>
);
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { HydrateClient, api } from "~/trpc/server";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
export default async function TimeClockPage({
@@ -17,7 +18,7 @@ export default async function TimeClockPage({
}
return (
<div className="page-enter space-y-6">
<DashboardPage>
<DashboardPageHeader
title="Time clock"
description="Track billable hours and save them directly to an invoice"
@@ -28,6 +29,6 @@ export default async function TimeClockPage({
defaultInvoiceId={params.invoiceId}
/>
</HydrateClient>
</div>
</DashboardPage>
);
}
+5 -51
View File
@@ -4,14 +4,7 @@ import { type Metadata } from "next";
import localFont from "next/font/local";
import { Toaster } from "~/components/ui/sonner";
import {
brand,
defaultBodyFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
} from "~/lib/branding";
import { brand } from "~/lib/branding";
import { UmamiScript } from "~/components/analytics/umami-script";
import { BrandBackground } from "~/components/layout/brand-background";
@@ -34,23 +27,6 @@ const playfair = localFont({
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({
src: "../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
variable: "--font-geist-mono",
@@ -64,14 +40,8 @@ export default function RootLayout({
<html
suppressHydrationWarning
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-theme="slate"
className={`${geistSans.variable} ${playfair.variable} ${frutiger.variable} ${geistMono.variable}`}
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
>
<head>
<script
@@ -79,27 +49,11 @@ export default function RootLayout({
dangerouslySetInnerHTML={{
__html: `
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 appearance = Object.assign(defaults, stored);
var colorMode = stored.colorMode || "system";
var root = document.documentElement;
root.dataset.interfaceTheme = appearance.interfaceTheme;
root.dataset.bodyFont = appearance.bodyFontPreference;
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);
root.dataset.colorMode = colorMode;
if (colorMode === "dark") root.classList.add("dark");
} catch {}
`,
}}