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 {}
`,
}}
+6 -10
View File
@@ -2,7 +2,6 @@
import { motion } from "framer-motion";
import { brand } from "~/lib/branding";
import { useAppearance } from "~/components/providers/appearance-provider";
import { cn } from "~/lib/utils";
interface LogoProps {
@@ -25,10 +24,7 @@ function splitLogoText(logoText: string) {
}
export function Logo({ className, size = "md", animated = true }: LogoProps) {
const appearance = useAppearance();
const logoText = appearance.brandLogoText || brand.logoText;
const icon = appearance.brandIcon || brand.icon;
const [logoPrefix, logoSuffix] = splitLogoText(logoText);
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
const sizeClasses = {
sm: "text-base",
md: "text-xl",
@@ -45,7 +41,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
sizeClasses={sizeClasses}
logoPrefix={logoPrefix}
logoSuffix={logoSuffix}
icon={icon}
icon={brand.icon}
/>
);
}
@@ -67,7 +63,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
className="text-primary font-bold tracking-tight"
>
{icon}
{brand.icon}
</motion.span>
{size !== "icon" && (
<>
@@ -75,8 +71,8 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
className="inline-block w-1" // Reduced from w-2 to w-1 (half space)
></motion.span>
className="inline-block w-1"
/>
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -125,7 +121,7 @@ function LogoContent({
<span className="text-primary font-bold tracking-tight">{icon}</span>
{size !== "icon" && (
<>
<span className="inline-block w-1"></span>
<span className="inline-block w-1" />
<span className="text-foreground font-bold tracking-tight">
{logoPrefix}
</span>
@@ -0,0 +1,25 @@
"use client";
import type { ReactElement } from "react";
import { ResponsiveContainer } from "recharts";
import { cn } from "~/lib/utils";
interface ResponsiveChartProps {
height?: number;
className?: string;
children: ReactElement;
}
export function ResponsiveChart({
height = 256,
className,
children,
}: ResponsiveChartProps) {
return (
<div className={cn("w-full min-w-0", className)}>
<ResponsiveContainer width="100%" height={height} minWidth={0}>
{children}
</ResponsiveContainer>
</div>
);
}
+14 -9
View File
@@ -20,7 +20,9 @@ import { useEffect, useState } from "react";
import { toast } from "sonner";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
@@ -408,7 +410,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
(mode === "edit" && isLoadingEmailConfig)
) {
return (
<div className="space-y-6 pb-32">
<DashboardPage className="pb-32">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
@@ -430,21 +432,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</div>
</CardContent>
</Card>
</div>
</DashboardPage>
);
}
return (
<>
<div className="space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title={mode === "edit" ? "Edit Business" : "Add Business"}
description={
mode === "edit"
? "Update business information below"
: "Enter business details below to add a new business."
}
variant="gradient"
>
<Button
type="submit"
@@ -469,9 +470,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</>
)}
</Button>
</PageHeader>
</DashboardPageHeader>
<form id="business-form" onSubmit={handleSubmit} className="space-y-6">
<form
id="business-form"
onSubmit={handleSubmit}
className={cn("flex flex-col", dashboardGapClass)}
>
{/* Main Form Container - styled like data table */}
<div className="space-y-4">
{/* Basic Information */}
@@ -902,7 +907,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</Card>
</div>
</form>
</div>
</DashboardPage>
<FloatingActionBar
leftContent={
+14 -9
View File
@@ -19,7 +19,9 @@ import { Label } from "~/components/ui/label";
import { Skeleton } from "~/components/ui/skeleton";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
import { NumberInput } from "~/components/ui/number-input";
import { api } from "~/trpc/react";
import {
@@ -237,7 +239,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
if (mode === "edit" && isLoadingClient) {
return (
<div className="space-y-6 pb-32">
<DashboardPage className="pb-32">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
@@ -259,21 +261,20 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</div>
</CardContent>
</Card>
</div>
</DashboardPage>
);
}
return (
<>
<div className="space-y-6 pb-32">
<PageHeader
<DashboardPage className="pb-32">
<DashboardPageHeader
title={mode === "edit" ? "Edit Client" : "Add Client"}
description={
mode === "edit"
? "Update client information below"
: "Enter client details below to add a new client."
}
variant="gradient"
>
<Button
type="submit"
@@ -298,9 +299,13 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</>
)}
</Button>
</PageHeader>
</DashboardPageHeader>
<form id="client-form" onSubmit={handleSubmit} className="space-y-6">
<form
id="client-form"
onSubmit={handleSubmit}
className={cn("flex flex-col", dashboardGapClass)}
>
{/* Main Form Container - styled like data table */}
<div className="space-y-4">
{/* Basic Information */}
@@ -508,7 +513,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
</Card>
</div>
</form>
</div>
</DashboardPage>
<FloatingActionBar
leftContent={
+2 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { getAppUrl } from "~/lib/app-url";
interface EmailPreviewProps {
subject: string;
@@ -89,10 +90,7 @@ export function EmailPreview({
customMessage: customMessage,
userName: invoice.business?.name ?? "Your Business",
userEmail: fromEmail,
baseUrl:
typeof window !== "undefined"
? window.location.origin
: "https://beenvoice.app",
baseUrl: getAppUrl(),
})
: null;
+55 -81
View File
@@ -6,7 +6,18 @@ import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Label } from "~/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
} from "~/components/layout/page-tabs";
import {
DashboardPage,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { cn } from "~/lib/utils";
import {
Select,
SelectContent,
@@ -17,7 +28,6 @@ import {
import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { PageHeader } from "~/components/layout/page-header";
import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view";
import { EmailPreview } from "./email-preview";
@@ -62,19 +72,17 @@ interface InvoiceFormProps {
function InvoiceFormSkeleton() {
return (
<div className="space-y-6 pb-8">
<PageHeader
<DashboardPage className="pb-8">
<DashboardPageHeader
title="Loading..."
description="Loading invoice form"
variant="gradient"
/>
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />{" "}
{/* Tabs Skeleton */}
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
</div>
</div>
</DashboardPage>
);
}
@@ -462,8 +470,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return (
<>
<div className="page-enter space-y-6 pb-8">
<PageHeader
<DashboardPage className="pb-8">
<DashboardPageHeader
title={
invoiceId !== "new"
? "Edit Invoice"
@@ -476,7 +484,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
? "Set up a draft to clock time into later"
: "Manage your invoice"
}
variant="gradient"
>
{invoiceId !== "new" && (
<Button
@@ -491,42 +498,28 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Save className="mr-2 h-4 w-4" />
{loading ? "Saving..." : "Save"}
</Button>
</PageHeader>
</DashboardPageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
{/* TAB SELECTOR: w-full, p-1, visible background */}
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1 lg:grid-cols-3">
<TabsTrigger
value="details"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Details
</TabsTrigger>
<TabsTrigger
value="items"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Items
</TabsTrigger>
<TabsTrigger
value="timesheet"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Timesheet
</TabsTrigger>
<TabsTrigger
value="preview"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm lg:hidden"
>
<div
className={cn(
dashboardGridClass,
"lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]",
)}
>
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
<PageTabsList>
<PageTabsTrigger value="details">Details</PageTabsTrigger>
<PageTabsTrigger value="items">Items</PageTabsTrigger>
<PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
<PageTabsTrigger value="preview" className="lg:hidden">
Preview
</TabsTrigger>
</TabsList>
</PageTabsTrigger>
</PageTabsList>
{/* DETAILS TAB */}
<TabsContent
<PageTabsContent
value="details"
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
className={cn(dashboardGridClass, "lg:grid-cols-2")}
>
<Card className="h-full">
<CardHeader>
@@ -770,13 +763,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
{/* ITEMS TAB */}
<TabsContent
value="items"
className="mt-6 focus-visible:outline-none"
>
<PageTabsContent value="items">
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<Card className="bg-primary/5 border-primary/20">
<CardContent className="flex items-center justify-between p-4">
@@ -826,13 +816,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
{/* TIMESHEET TAB */}
<TabsContent
value="timesheet"
className="mt-6 focus-visible:outline-none"
>
<PageTabsContent value="timesheet">
<Card className="min-h-[600px] w-full">
<CardHeader>
<CardTitle className="flex gap-2">
@@ -850,37 +837,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</TabsContent>
</PageTabsContent>
<TabsContent
value="preview"
className="mt-6 focus-visible:outline-none"
>
<Tabs
<PageTabsContent value="preview">
<PageTabs
value={previewTab}
onValueChange={setPreviewTab}
className="w-full"
>
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
<TabsTrigger
value="pdf"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
PDF
</TabsTrigger>
<TabsTrigger
value="email"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Email
</TabsTrigger>
</TabsList>
<PageTabsList>
<PageTabsTrigger value="pdf">PDF</PageTabsTrigger>
<PageTabsTrigger value="email">Email</PageTabsTrigger>
</PageTabsList>
<TabsContent value="pdf" className="mt-6">
<PageTabsContent value="pdf">
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
</TabsContent>
</PageTabsContent>
<TabsContent value="email" className="mt-6">
<PageTabsContent value="email">
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
@@ -928,10 +902,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
</PageTabsContent>
</PageTabs>
</PageTabsContent>
</PageTabs>
<aside className="hidden lg:block">
<div className="sticky top-4 space-y-4">
@@ -950,7 +924,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
</aside>
</div>
</div>
</DashboardPage>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
+59
View File
@@ -0,0 +1,59 @@
import { cn } from "~/lib/utils";
/** Vertical rhythm for dashboard pages — use with shell `gap-5`. */
export const dashboardGapClass = "gap-5 md:gap-6";
/** Standard grid gap for dashboard cards and sections. */
export const dashboardGridClass =
"grid gap-5 md:gap-6";
/** Summary stat cards (2-up mobile, 4-up desktop). */
export const dashboardStatGridClass =
"grid grid-cols-2 gap-4 sm:grid-cols-4";
export function DashboardPage({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"page-enter mx-auto flex w-full max-w-7xl flex-col",
dashboardGapClass,
className,
)}
>
{children}
</div>
);
}
export function DashboardGrid({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn(dashboardGridClass, className)}>{children}</div>
);
}
export function DashboardCardTitle({
children,
icon: Icon,
}: {
children: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
}) {
return (
<span className="flex items-center gap-2">
{Icon ? <Icon className="text-muted-foreground h-4 w-4" /> : null}
{children}
</span>
);
}
+21 -28
View File
@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { usePathname } from "next/navigation";
import { Sidebar } from "~/components/layout/sidebar";
import {
SidebarProvider,
@@ -11,23 +12,29 @@ import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo";
import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { useAppearance } from "~/components/providers/appearance-provider";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
import { OnboardingGuard } from "~/components/layout/onboarding-guard";
function DashboardContent({ children }: { children: React.ReactNode }) {
const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
const pathname = usePathname();
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
const isOnboarding = pathname === "/dashboard/onboarding";
return (
<div className="bg-dashboard relative flex min-h-screen">
{/* Desktop Sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
{!isOnboarding && (
<div className="hidden md:block">
<Sidebar />
</div>
)}
{/* Mobile Sidebar (Sheet) */}
<div className="dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden">
<div
className={cn(
"dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden",
isOnboarding && "hidden",
)}
>
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
<SheetTrigger asChild>
<Button
@@ -40,9 +47,9 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
{/* Mobile Link / Logo */}
<div className="ml-4 flex items-center gap-2">
<div className="ml-4 flex min-w-0 flex-1 items-center gap-2">
<Logo size="sm" />
<ActiveTimerWidget compact />
</div>
<SheetContent side="left" className="w-72 p-0">
<div className="sr-only">
@@ -53,29 +60,15 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
</Sheet>
</div>
{/* Main Content */}
<main
suppressHydrationWarning
className={cn(
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
"md:ml-0",
sidebarStyle === "floating"
? isCollapsed
? "md:ml-24"
: "md:ml-[18rem]"
: isCollapsed
? "md:ml-16"
: "md:ml-64",
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0",
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
)}
>
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
<div className="mb-4 md:hidden">
{/* Mobile Breadcrumbs could go here or be part of the page */}
</div>
<div className="mb-4">
<ActiveTimerWidget />
</div>
{children}
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
<OnboardingGuard>{children}</OnboardingGuard>
</div>
</main>
</div>
@@ -0,0 +1,29 @@
"use client";
import { createContext, useContext } from "react";
interface DashboardUserContextValue {
isAdmin: boolean;
needsOnboarding: boolean;
}
const DashboardUserContext = createContext<DashboardUserContextValue>({
isAdmin: false,
needsOnboarding: false,
});
export function DashboardUserProvider({
isAdmin,
needsOnboarding,
children,
}: DashboardUserContextValue & { children: React.ReactNode }) {
return (
<DashboardUserContext.Provider value={{ isAdmin, needsOnboarding }}>
{children}
</DashboardUserContext.Provider>
);
}
export function useDashboardUser() {
return useContext(DashboardUserContext);
}
+1 -12
View File
@@ -3,15 +3,11 @@
import React from "react";
import { cn } from "~/lib/utils";
import { Card, CardContent } from "~/components/ui/card";
import { useAppearance } from "~/components/providers/appearance-provider";
import { useSidebar } from "~/components/layout/sidebar-provider";
interface FloatingActionBarProps {
/** Content to display on the left side */
leftContent?: React.ReactNode;
/** Action buttons to display on the right */
children: React.ReactNode;
/** Additional className for styling */
className?: string;
}
@@ -21,19 +17,12 @@ export function FloatingActionBar({
className,
}: FloatingActionBarProps) {
const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
return (
<div
className={cn(
"pb-safe-area-inset-bottom fixed right-0 bottom-4 left-0 z-50 transition-all duration-300 ease-in-out",
sidebarStyle === "floating"
? isCollapsed
? "md:left-24"
: "md:left-[18rem]"
: isCollapsed
? "md:left-16"
: "md:left-64",
isCollapsed ? "md:left-16" : "md:left-64",
"animate-slide-in-bottom",
className,
)}
@@ -0,0 +1,24 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useDashboardUser } from "./dashboard-user-context";
export function OnboardingGuard({ children }: { children: React.ReactNode }) {
const { needsOnboarding } = useDashboardUser();
const pathname = usePathname();
const router = useRouter();
const onOnboardingPage = pathname === "/dashboard/onboarding";
useEffect(() => {
if (needsOnboarding && !onOnboardingPage) {
router.replace("/dashboard/onboarding");
}
}, [needsOnboarding, onOnboardingPage, router]);
if (needsOnboarding && !onOnboardingPage) {
return null;
}
return children;
}
+5 -3
View File
@@ -1,5 +1,6 @@
import React from "react";
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
import { cn } from "~/lib/utils";
interface PageHeaderProps {
title: string;
@@ -40,7 +41,7 @@ export function PageHeader({
};
return (
<div className={`animate-fade-in-down mb-6 ${className}`}>
<div className={cn("animate-fade-in-down", className)}>
{variant === "large-gradient" || variant === "gradient" ? (
<div className="platform-header-surface bg-card text-card-foreground relative overflow-hidden rounded-xl border shadow-sm">
<div className="platform-header-gradient from-primary/5 pointer-events-none absolute inset-0 bg-gradient-to-br via-transparent to-transparent" />
@@ -104,8 +105,9 @@ export function DashboardPageHeader({
<PageHeader
title={title}
description={description}
variant="large-gradient"
className={className}
variant="gradient"
className={cn("mb-0", className)}
titleClassName="font-heading text-2xl font-semibold tracking-tight sm:text-3xl"
>
{children}
</PageHeader>
+75
View File
@@ -0,0 +1,75 @@
"use client";
import * as React from "react";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "~/components/ui/tabs";
import { dashboardGapClass, dashboardGridClass } from "~/components/layout/dashboard-page";
import { cn } from "~/lib/utils";
/** Vertical rhythm inside tab panels — matches dashboard page sections. */
export const pageTabsPanelClass = dashboardGapClass;
/** Grid for stacked cards inside a tab panel. */
export const pageTabsGridClass = dashboardGridClass;
type PageTabsProps = React.ComponentPropsWithoutRef<typeof Tabs>;
export function PageTabs({ className, ...props }: PageTabsProps) {
return (
<Tabs
className={cn("flex flex-col", pageTabsPanelClass, className)}
{...props}
/>
);
}
type PageTabsListProps = React.ComponentPropsWithoutRef<typeof TabsList>;
export function PageTabsList({ className, ...props }: PageTabsListProps) {
return (
<div className="-mx-1 overflow-x-auto px-1 pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<TabsList
className={cn(
"bg-muted/50 border-border/60 inline-flex h-10 w-max min-w-full gap-0.5 rounded-xl border p-1 sm:min-w-0",
className,
)}
{...props}
/>
</div>
);
}
export function PageTabsTrigger({
className,
...props
}: React.ComponentPropsWithoutRef<typeof TabsTrigger>) {
return (
<TabsTrigger
className={cn(
"data-[state=active]:bg-background h-8 rounded-lg px-3.5 text-sm data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
);
}
export function PageTabsContent({
className,
...props
}: React.ComponentPropsWithoutRef<typeof TabsContent>) {
return (
<TabsContent
className={cn(
"mt-0 flex flex-col focus-visible:ring-0 focus-visible:outline-none",
pageTabsPanelClass,
className,
)}
{...props}
/>
);
}
+9 -8
View File
@@ -6,7 +6,7 @@ import { authClient } from "~/lib/auth-client";
import { Skeleton } from "~/components/ui/skeleton";
import { Button } from "~/components/ui/button";
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { useSidebar } from "./sidebar-provider";
import { cn } from "~/lib/utils";
import { Logo } from "~/components/branding/logo";
@@ -26,8 +26,9 @@ import {
} from "~/components/ui/dropdown-menu";
import { getGravatarUrl } from "~/lib/gravatar";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { useAppearance } from "~/components/providers/appearance-provider";
import { useAuthSession } from "~/hooks/use-auth-session";
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
interface SidebarProps {
mobile?: boolean;
@@ -37,8 +38,9 @@ interface SidebarProps {
export function Sidebar({ mobile, onClose }: SidebarProps) {
const pathname = usePathname();
const { data: session, isPending } = useAuthSession();
const { isAdmin } = useDashboardUser();
const { isCollapsed, toggleCollapse } = useSidebar();
const { sidebarStyle } = useAppearance();
const navSections = getNavigationForUser(isAdmin);
// If mobile, always expanded
const collapsed = mobile ? false : isCollapsed;
@@ -72,7 +74,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
collapsed && "items-center",
)}
>
{navigationConfig.map((section) => (
{navSections.map((section) => (
<div key={section.title}>
{!collapsed && (
<div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase">
@@ -163,6 +165,8 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
</div>
)}
<ActiveTimerWidget collapsed={collapsed} />
<div
className={cn(
"border-border/50 border-t pt-4",
@@ -265,10 +269,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
return (
<aside
className={cn(
"fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex",
sidebarStyle === "floating"
? "border-border/50 bg-background/80 top-4 bottom-4 left-4 rounded-3xl border shadow-xl backdrop-blur-xl"
: "border-border bg-background top-0 bottom-0 left-0 rounded-none border-r shadow-none",
"border-border bg-background fixed top-0 bottom-0 left-0 z-30 hidden flex-col rounded-none border-r shadow-none transition-all duration-300 ease-in-out md:flex",
isCollapsed ? "w-16" : "w-64",
)}
>
+6 -3
View File
@@ -9,8 +9,11 @@ import {
Users,
} from "lucide-react";
import { BrowserFrame } from "~/components/marketing/browser-frame";
import { getAppHost } from "~/lib/app-url";
import { cn } from "~/lib/utils";
const appHost = getAppHost();
function MockSidebar({ active }: { active: "dashboard" | "invoices" | "time" }) {
const items = [
{ id: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
@@ -87,7 +90,7 @@ export function InvoicesScreenshot({ className }: { className?: string }) {
];
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard/invoices">
<BrowserFrame className={className} url={`${appHost}/dashboard/invoices`}>
<div className="flex min-h-[280px] sm:min-h-[320px]">
<MockSidebar active="invoices" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
@@ -134,7 +137,7 @@ export function InvoicesScreenshot({ className }: { className?: string }) {
export function TimeClockScreenshot({ className }: { className?: string }) {
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard/time-clock">
<BrowserFrame className={className} url={`${appHost}/dashboard/time-clock`}>
<div className="flex min-h-[260px] sm:min-h-[300px]">
<MockSidebar active="time" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
@@ -204,7 +207,7 @@ export function TimeClockScreenshot({ className }: { className?: string }) {
export function DashboardScreenshot({ className }: { className?: string }) {
return (
<BrowserFrame className={className} url="beenvoice.app/dashboard">
<BrowserFrame className={className} url={`${appHost}/dashboard`}>
<div className="flex min-h-[260px] sm:min-h-[300px]">
<MockSidebar active="dashboard" />
<div className="min-w-0 flex-1 p-4 sm:p-5">
+2 -1
View File
@@ -1,9 +1,10 @@
import { getAppHost } from "~/lib/app-url";
import { cn } from "~/lib/utils";
export function BrowserFrame({
children,
className,
url = "beenvoice.app/dashboard",
url = `${getAppHost()}/dashboard`,
}: {
children: React.ReactNode;
className?: string;
@@ -6,7 +6,8 @@ import { usePathname } from "next/navigation";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { useAuthSession } from "~/hooks/use-auth-session";
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
interface SidebarTriggerProps {
isOpen: boolean;
@@ -16,6 +17,8 @@ interface SidebarTriggerProps {
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
const pathname = usePathname();
const { isPending } = useAuthSession();
const { isAdmin } = useDashboardUser();
const navSections = getNavigationForUser(isAdmin);
return (
<>
@@ -34,7 +37,7 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
<div className="bg-background border-border absolute top-full right-0 left-0 z-40 mt-1 border-t">
{/* Navigation content */}
<nav className="flex flex-col p-4">
{navigationConfig.map((section, sectionIndex) => (
{navSections.map((section, sectionIndex) => (
<div
key={section.title}
className={sectionIndex > 0 ? "mt-4" : ""}
@@ -177,7 +177,6 @@ export function AnimationPreferencesProviderSynced({
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect
performUpdate(
{
prefersReducedMotion: serverPrefs.prefersReducedMotion,
@@ -187,12 +186,9 @@ export function AnimationPreferencesProviderSynced({
);
}
serverHydratedRef.current = true;
}, [
serverPrefs,
performUpdate,
prefersReducedMotion,
animationSpeedMultiplier,
]);
// One-time hydration from server after local storage is read.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverPrefs]);
const updatePreferences = useCallback<
AnimationPreferencesContextValue["updatePreferences"]
@@ -1,232 +1,88 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isHslChannels } from "~/lib/appearance";
import type { ColorMode, ColorTheme, FontPreference, InterfaceTheme, RadiusPreference, SidebarStyle } from "~/lib/branding";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { defaultColorMode, type ColorMode } from "~/lib/appearance";
import { api } from "~/trpc/react";
import {
AppearanceContext,
applyAppearance,
applyColorMode,
defaultAppearance,
readStoredAppearance,
writeStoredAppearance,
readStoredColorMode,
writeStoredColorMode,
type AppearanceContextValue,
type AppearancePatch,
type AppearancePreferences,
} from "~/components/providers/appearance-provider";
type ServerAppearance = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
theme: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: AppearancePreferences["pdfTemplate"];
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
function getServerAppearancePatch(
serverAppearance: ServerAppearance,
): AppearancePatch {
return {
interfaceTheme: serverAppearance.interfaceTheme,
bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference,
sidebarStyle: serverAppearance.sidebarStyle,
colorMode: serverAppearance.theme,
colorTheme: serverAppearance.colorTheme,
customColor: serverAppearance.customColor,
brandName: serverAppearance.brandName,
brandTagline: serverAppearance.brandTagline,
brandLogoText: serverAppearance.brandLogoText,
brandIcon: serverAppearance.brandIcon,
pdfTemplate: serverAppearance.pdfTemplate,
pdfAccentColor: serverAppearance.pdfAccentColor,
pdfFooterText: serverAppearance.pdfFooterText,
pdfShowLogo: serverAppearance.pdfShowLogo,
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
};
}
/** Dashboard appearance provider with tRPC theme sync. Must render inside TRPCReactProvider. */
/** Dashboard appearance provider with per-user color mode sync. */
export function AppearanceProviderSynced({
children,
}: {
children: React.ReactNode;
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
const serverHydratedRef = useRef(false);
const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => {
await utils.settings.getTheme.invalidate();
},
const updateMutation = api.settings.updateColorMode.useMutation({
onError: () => {
const cachedAppearance = utils.settings.getTheme.getData();
const fallback = cachedAppearance
? {
...defaultAppearance,
...getServerAppearancePatch(cachedAppearance),
}
: defaultAppearance;
setAppearance(fallback);
applyAppearance(fallback);
writeStoredAppearance(fallback);
const cached = utils.settings.getColorMode.getData();
const fallback = cached?.colorMode ?? defaultColorMode;
setColorMode(fallback);
applyColorMode(fallback);
writeStoredColorMode(fallback);
},
});
const persistAppearance = useCallback(
(patch: AppearancePatch) => {
if (
patch.customColor !== undefined &&
!isHslChannels(patch.customColor)
) {
return;
}
const { data: serverColorMode } = api.settings.getColorMode.useQuery(
undefined,
{
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
},
);
updateMutation.mutate({
interfaceTheme: patch.interfaceTheme,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
useEffect(() => {
const stored = readStoredColorMode();
if (stored) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(stored);
}
}, []);
useEffect(() => {
if (!serverColorMode?.colorMode) return;
if (serverHydratedRef.current) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(serverColorMode.colorMode);
serverHydratedRef.current = true;
}, [serverColorMode?.colorMode]);
useEffect(() => {
applyColorMode(colorMode);
writeStoredColorMode(colorMode);
}, [colorMode]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
if (!patch.colorMode) return;
setColorMode(patch.colorMode);
applyColorMode(patch.colorMode);
writeStoredColorMode(patch.colorMode);
updateMutation.mutate({ colorMode: patch.colorMode });
},
[updateMutation],
);
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
useEffect(() => {
const storedAppearance = readStoredAppearance();
if (!storedAppearance) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
}, []);
useEffect(() => {
if (!serverAppearance) return;
const next = getServerAppearancePatch(serverAppearance);
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...next }));
}, [serverAppearance]);
useEffect(() => {
applyAppearance(appearance);
writeStoredAppearance(appearance);
}, [appearance]);
const updateAppearance = useCallback(
(patch: AppearancePatch) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
}
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
persistAppearance(patch);
},
[persistAppearance],
);
const updateAppearanceDebounced = useCallback(
(patch: AppearancePatch) => {
pendingDebouncedPatchRef.current = {
...pendingDebouncedPatchRef.current,
...patch,
};
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
persistAppearance(pendingDebouncedPatchRef.current);
pendingDebouncedPatchRef.current = {};
debounceTimerRef.current = null;
}, 500);
},
[persistAppearance],
);
useEffect(
() => () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
pendingDebouncedPatchRef.current = {};
},
[],
);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
...defaultAppearance,
colorMode,
updateAppearance,
updateAppearanceDebounced,
isUpdating: updateMutation.isPending,
}),
[
appearance,
updateAppearance,
updateAppearanceDebounced,
updateMutation.isPending,
],
[colorMode, updateAppearance, updateMutation.isPending],
);
return (
+26 -166
View File
@@ -8,181 +8,53 @@ import {
useMemo,
useState,
} from "react";
import {
fallbackAppearance,
isColorMode,
isColorTheme,
isFontPreference,
isHslChannels,
isInterfaceTheme,
isPdfTemplate,
isRadiusPreference,
isSidebarStyle,
type PdfTemplate,
} from "~/lib/appearance";
import {
defaultBodyFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
brand as defaultBrand,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
import { defaultColorMode, isColorMode, type ColorMode } from "~/lib/appearance";
export type AppearancePreferences = {
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
colorMode: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
export type AppearancePatch = Partial<AppearancePreferences>;
export type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void;
updateAppearanceDebounced: (patch: AppearancePatch) => void;
isUpdating: boolean;
};
export const STORAGE_KEY = "bv.appearance";
export const defaultAppearance: AppearancePreferences = {
interfaceTheme: defaultInterfaceTheme,
bodyFontPreference: defaultBodyFontPreference,
headingFontPreference: defaultHeadingFontPreference,
radiusPreference: defaultRadiusPreference,
sidebarStyle: defaultSidebarStyle,
colorMode: fallbackAppearance.colorMode,
colorTheme: fallbackAppearance.colorTheme,
brandName: defaultBrand.name,
brandTagline: defaultBrand.tagline,
brandLogoText: defaultBrand.logoText,
brandIcon: defaultBrand.icon,
pdfTemplate: fallbackAppearance.pdfTemplate,
pdfAccentColor: fallbackAppearance.pdfAccentColor,
pdfFooterText: fallbackAppearance.pdfFooterText,
pdfShowLogo: fallbackAppearance.pdfShowLogo,
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
colorMode: defaultColorMode,
};
export const AppearanceContext =
createContext<AppearanceContextValue | null>(null);
export function readStoredAppearance(): Partial<AppearancePreferences> | null {
export function readStoredColorMode(): ColorMode | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
? parsed.interfaceTheme
: undefined,
bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
? parsed.bodyFontPreference
: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
headingFontPreference: isFontPreference(parsed.headingFontPreference)
? parsed.headingFontPreference
: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: undefined,
radiusPreference: isRadiusPreference(parsed.radiusPreference)
? parsed.radiusPreference
: undefined,
sidebarStyle: isSidebarStyle(parsed.sidebarStyle)
? parsed.sidebarStyle
: undefined,
colorMode: isColorMode(parsed.colorMode) ? parsed.colorMode : undefined,
colorTheme: isColorTheme(parsed.colorTheme)
? parsed.colorTheme
: undefined,
customColor: isHslChannels(parsed.customColor)
? parsed.customColor
: undefined,
brandName:
typeof parsed.brandName === "string" ? parsed.brandName : undefined,
brandTagline:
typeof parsed.brandTagline === "string"
? parsed.brandTagline
: undefined,
brandLogoText:
typeof parsed.brandLogoText === "string"
? parsed.brandLogoText
: undefined,
brandIcon:
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
pdfTemplate: isPdfTemplate(parsed.pdfTemplate)
? parsed.pdfTemplate
: undefined,
pdfAccentColor:
typeof parsed.pdfAccentColor === "string"
? parsed.pdfAccentColor
: undefined,
pdfFooterText:
typeof parsed.pdfFooterText === "string"
? parsed.pdfFooterText
: undefined,
pdfShowLogo:
typeof parsed.pdfShowLogo === "boolean"
? parsed.pdfShowLogo
: undefined,
pdfShowPageNumbers:
typeof parsed.pdfShowPageNumbers === "boolean"
? parsed.pdfShowPageNumbers
: undefined,
};
const parsed = JSON.parse(raw) as { colorMode?: unknown };
return isColorMode(parsed.colorMode) ? parsed.colorMode : null;
} catch {
return null;
}
}
export function writeStoredAppearance(prefs: AppearancePreferences) {
export function writeStoredColorMode(colorMode: ColorMode) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
localStorage.setItem(STORAGE_KEY, JSON.stringify({ colorMode }));
} catch {
// Storage can be unavailable in private browsing or locked-down contexts.
}
}
export function applyAppearance(prefs: AppearancePreferences) {
export function applyColorMode(colorMode: ColorMode) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.dataset.interfaceTheme = prefs.interfaceTheme;
root.dataset.bodyFont = prefs.bodyFontPreference;
root.dataset.headingFont = prefs.headingFontPreference;
root.dataset.radius = prefs.radiusPreference;
root.dataset.sidebarStyle = prefs.sidebarStyle;
root.dataset.colorMode = prefs.colorMode;
root.dataset.colorTheme = prefs.colorTheme;
root.classList.toggle("dark", prefs.colorMode === "dark");
if (prefs.customColor) {
root.style.setProperty("--custom-primary", prefs.customColor);
} else {
root.style.removeProperty("--custom-primary");
}
root.dataset.colorMode = colorMode;
root.classList.toggle("dark", colorMode === "dark");
}
/** Local-only appearance provider for marketing and auth pages (no tRPC). */
@@ -191,48 +63,36 @@ export function AppearanceProvider({
}: {
children: React.ReactNode;
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
useEffect(() => {
const storedAppearance = readStoredAppearance();
if (!storedAppearance) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
const stored = readStoredColorMode();
if (stored) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(stored);
}
}, []);
useEffect(() => {
applyAppearance(appearance);
writeStoredAppearance(appearance);
}, [appearance]);
applyColorMode(colorMode);
writeStoredColorMode(colorMode);
}, [colorMode]);
const updateAppearance = useCallback((patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
}, []);
const updateAppearanceDebounced = useCallback((patch: AppearancePatch) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
if (patch.colorMode) {
setColorMode(patch.colorMode);
applyColorMode(patch.colorMode);
writeStoredColorMode(patch.colorMode);
}
}, []);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
colorMode,
updateAppearance,
updateAppearanceDebounced,
isUpdating: false,
}),
[appearance, updateAppearance, updateAppearanceDebounced],
[colorMode, updateAppearance],
);
return (
+2 -2
View File
@@ -11,7 +11,7 @@ const Tabs = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.Root
ref={ref}
className={cn("flex flex-col gap-1", className)}
className={cn("flex flex-col gap-2", className)}
{...props}
/>
));
@@ -54,7 +54,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
className,
)}
{...props}
-34
View File
@@ -56,31 +56,6 @@ export const env = createEnv({
NEXT_PUBLIC_BRAND_TAGLINE: z.string().optional(),
NEXT_PUBLIC_BRAND_LOGO_TEXT: z.string().optional(),
NEXT_PUBLIC_BRAND_ICON: z.string().optional(),
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME: z
.enum([
"beenvoice",
"frutiger",
"frutiger-aero",
"shadcn",
"minimal",
"editorial",
])
.optional(),
NEXT_PUBLIC_DEFAULT_FONT: z
.enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_BODY_FONT: z
.enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_HEADING_FONT: z
.enum(["brand", "frutiger", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_RADIUS: z
.enum(["none", "sm", "md", "lg", "xl"])
.optional(),
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE: z
.enum(["floating", "docked"])
.optional(),
},
/**
@@ -109,15 +84,6 @@ export const env = createEnv({
NEXT_PUBLIC_BRAND_TAGLINE: process.env.NEXT_PUBLIC_BRAND_TAGLINE,
NEXT_PUBLIC_BRAND_LOGO_TEXT: process.env.NEXT_PUBLIC_BRAND_LOGO_TEXT,
NEXT_PUBLIC_BRAND_ICON: process.env.NEXT_PUBLIC_BRAND_ICON,
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME:
process.env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME,
NEXT_PUBLIC_DEFAULT_FONT: process.env.NEXT_PUBLIC_DEFAULT_FONT,
NEXT_PUBLIC_DEFAULT_BODY_FONT: process.env.NEXT_PUBLIC_DEFAULT_BODY_FONT,
NEXT_PUBLIC_DEFAULT_HEADING_FONT:
process.env.NEXT_PUBLIC_DEFAULT_HEADING_FONT,
NEXT_PUBLIC_DEFAULT_RADIUS: process.env.NEXT_PUBLIC_DEFAULT_RADIUS,
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE:
process.env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
+6
View File
@@ -0,0 +1,6 @@
export const APP_EMAIL_DOMAIN = "beenvoice.app";
export const PRIVACY_EMAIL = `privacy@${APP_EMAIL_DOMAIN}`;
export const LEGAL_EMAIL = `legal@${APP_EMAIL_DOMAIN}`;
export const SUPPORT_EMAIL = `support@${APP_EMAIL_DOMAIN}`;
export const NOREPLY_EMAIL = `noreply@${APP_EMAIL_DOMAIN}`;
+20
View File
@@ -0,0 +1,20 @@
/** Public app origin (no trailing slash). */
export function getAppUrl(): string {
if (typeof window !== "undefined") {
return window.location.origin.replace(/\/$/, "");
}
const fromEnv = process.env.NEXT_PUBLIC_APP_URL?.trim();
if (fromEnv) return fromEnv.replace(/\/$/, "");
return `http://localhost:${process.env.PORT ?? 3000}`;
}
/** Hostname for display (e.g. marketing browser chrome). */
export function getAppHost(): string {
try {
return new URL(getAppUrl()).host;
} catch {
return "beenvoice.app";
}
}
+4 -102
View File
@@ -1,126 +1,28 @@
import { z } from "zod";
export const interfaceThemeValues = [
"beenvoice",
"frutiger",
"frutiger-aero",
"shadcn",
"minimal",
"editorial",
] as const;
export const fontPreferenceValues = [
"brand",
"frutiger",
"platform",
"inter",
"serif",
] as const;
export const radiusPreferenceValues = ["none", "sm", "md", "lg", "xl"] as const;
export const sidebarStyleValues = ["floating", "docked"] as const;
export const colorModeValues = ["light", "dark", "system"] as const;
export const colorThemeValues = [
"slate",
"blue",
"green",
"rose",
"orange",
"custom",
] as const;
export const pdfTemplateValues = ["classic", "minimal"] as const;
export const interfaceThemeSchema = z.enum(interfaceThemeValues);
export const fontPreferenceSchema = z.enum(fontPreferenceValues);
export const radiusPreferenceSchema = z.enum(radiusPreferenceValues);
export const sidebarStyleSchema = z.enum(sidebarStyleValues);
export const colorModeSchema = z.enum(colorModeValues);
export const colorThemeSchema = z.enum(colorThemeValues);
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
export const hslChannelsSchema = z
.string()
.trim()
.regex(
/^(?:360(?:\.0)?|3[0-5]\d(?:\.\d)?|[12]?\d?\d(?:\.\d)?)\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%$/,
"Use HSL channels like 142.1 76.2% 36.3%",
);
export type InterfaceTheme = z.infer<typeof interfaceThemeSchema>;
export type FontPreference = z.infer<typeof fontPreferenceSchema>;
export type RadiusPreference = z.infer<typeof radiusPreferenceSchema>;
export type SidebarStyle = z.infer<typeof sidebarStyleSchema>;
export type ColorMode = z.infer<typeof colorModeSchema>;
export type ColorTheme = z.infer<typeof colorThemeSchema>;
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
export const fallbackAppearance = {
interfaceTheme: "beenvoice",
fontPreference: "brand",
bodyFontPreference: "brand",
headingFontPreference: "brand",
radiusPreference: "xl",
sidebarStyle: "floating",
colorMode: "system",
colorTheme: "slate",
customColor: undefined,
brandName: "beenvoice",
brandTagline:
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: "beenvoice",
brandIcon: "$",
pdfTemplate: "classic",
export const defaultColorMode: ColorMode = "system";
export const defaultPdfSettings = {
pdfTemplate: "classic" as PdfTemplate,
pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
} satisfies {
interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
colorMode: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
export function isInterfaceTheme(value: unknown): value is InterfaceTheme {
return interfaceThemeSchema.safeParse(value).success;
}
export function isFontPreference(value: unknown): value is FontPreference {
return fontPreferenceSchema.safeParse(value).success;
}
export function isColorMode(value: unknown): value is ColorMode {
return colorModeSchema.safeParse(value).success;
}
export function isColorTheme(value: unknown): value is ColorTheme {
return colorThemeSchema.safeParse(value).success;
}
export function isRadiusPreference(value: unknown): value is RadiusPreference {
return radiusPreferenceSchema.safeParse(value).success;
}
export function isSidebarStyle(value: unknown): value is SidebarStyle {
return sidebarStyleSchema.safeParse(value).success;
}
export function isPdfTemplate(value: unknown): value is PdfTemplate {
return pdfTemplateSchema.safeParse(value).success;
}
export function isHslChannels(value: unknown): value is string {
return hslChannelsSchema.safeParse(value).success;
}
+3 -7
View File
@@ -3,14 +3,10 @@
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";
function resolveAuthBaseUrl(): string | undefined {
// Always use the current origin in the browser so dev works on any port
// (e.g. 3002 when 3000 is taken), without rebuilding for NEXT_PUBLIC_APP_URL.
if (typeof window !== "undefined") {
return window.location.origin;
}
import { getAppUrl } from "~/lib/app-url";
return process.env.NEXT_PUBLIC_APP_URL;
function resolveAuthBaseUrl(): string | undefined {
return getAppUrl();
}
export const authClient = createAuthClient({
+25 -276
View File
@@ -1,292 +1,41 @@
import { env } from "~/env";
import {
fallbackAppearance,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type PdfTemplate,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/appearance";
export type {
ColorMode,
ColorTheme,
FontPreference,
InterfaceTheme,
PdfTemplate,
RadiusPreference,
SidebarStyle,
} from "~/lib/appearance";
import { defaultColorMode, type ColorMode } from "~/lib/appearance";
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
export {
colorModeSchema,
colorThemeSchema,
fallbackAppearance,
fontPreferenceSchema,
hslChannelsSchema,
interfaceThemeSchema,
defaultColorMode,
defaultPdfSettings,
pdfTemplateSchema,
radiusPreferenceSchema,
sidebarStyleSchema,
} from "~/lib/appearance";
export const interfaceThemes: {
value: InterfaceTheme;
label: string;
description: string;
}[] = [
{
value: "beenvoice",
label: "beenvoice",
description:
"Playfair Display headings, Geist body text, and soft product chrome.",
},
{
value: "frutiger",
label: "Frutiger Airport",
description:
"Rectangular blue-and-yellow wayfinding UI with Frutiger typography and docked navigation.",
},
{
value: "frutiger-aero",
label: "Frutiger Aero",
description:
"Glossy sky-and-glass interface with Frutiger typography and softer surfaces.",
},
{
value: "shadcn",
label: "shadcn/ui",
description: "A plain shadcn baseline for white-label starts.",
},
{
value: "minimal",
label: "Minimal",
description: "Quiet surfaces, lower contrast, and restrained chrome.",
},
{
value: "editorial",
label: "Editorial",
description: "A warmer presentation style for service-led brands.",
},
];
export const themePresets: Record<
InterfaceTheme,
{
interfaceTheme: InterfaceTheme;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
colorTheme: ColorTheme;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
pdfTemplate: PdfTemplate;
pdfAccentColor: string;
}
> = {
beenvoice: {
interfaceTheme: "beenvoice",
bodyFontPreference: "brand",
headingFontPreference: "brand",
colorTheme: "slate",
radiusPreference: "xl",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
},
frutiger: {
interfaceTheme: "frutiger",
bodyFontPreference: "frutiger",
headingFontPreference: "frutiger",
colorTheme: "blue",
radiusPreference: "none",
sidebarStyle: "docked",
pdfTemplate: "minimal",
pdfAccentColor: "#003b5c",
},
"frutiger-aero": {
interfaceTheme: "frutiger-aero",
bodyFontPreference: "frutiger",
headingFontPreference: "frutiger",
colorTheme: "blue",
radiusPreference: "lg",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#0077be",
},
shadcn: {
interfaceTheme: "shadcn",
bodyFontPreference: "inter",
headingFontPreference: "inter",
colorTheme: "slate",
radiusPreference: "md",
sidebarStyle: "docked",
pdfTemplate: "classic",
pdfAccentColor: "#111827",
},
minimal: {
interfaceTheme: "minimal",
bodyFontPreference: "platform",
headingFontPreference: "platform",
colorTheme: "slate",
radiusPreference: "sm",
sidebarStyle: "docked",
pdfTemplate: "minimal",
pdfAccentColor: "#111827",
},
editorial: {
interfaceTheme: "editorial",
bodyFontPreference: "platform",
headingFontPreference: "serif",
colorTheme: "rose",
radiusPreference: "lg",
sidebarStyle: "floating",
pdfTemplate: "classic",
pdfAccentColor: "#be123c",
},
};
export const bodyFontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Geist",
description: "Geist body text for the core beenvoice product feel.",
},
{
value: "frutiger",
label: "Frutiger",
description: "Frutiger body text for signage-like operational screens.",
},
{
value: "platform",
label: "Platform",
description: "Native system body text for the current OS.",
},
{
value: "inter",
label: "Geist Legacy",
description: "Legacy sans option mapped to Geist for older installs.",
},
{
value: "serif",
label: "Serif",
description: "Georgia-style body text for editorial deployments.",
},
];
export const headingFontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Playfair Display",
description: "Playfair Display headings for the beenvoice identity.",
},
{
value: "frutiger",
label: "Frutiger",
description: "Frutiger headings for airport-inspired wayfinding.",
},
{
value: "platform",
label: "Platform",
description: "Native system headings for a neutral app feel.",
},
{
value: "inter",
label: "Geist Legacy",
description: "Legacy sans option mapped to Geist for older installs.",
},
{
value: "serif",
label: "Editorial",
description: "Playfair headings with a stronger editorial tone.",
},
];
export const radiusPreferences: {
value: RadiusPreference;
label: string;
description: string;
}[] = [
{ value: "none", label: "Square", description: "No rounded corners." },
{ value: "sm", label: "Small", description: "Subtle 4px rounding." },
{ value: "md", label: "Medium", description: "Standard 8px rounding." },
{ value: "lg", label: "Large", description: "Soft 12px rounding." },
{
value: "xl",
label: "Extra Large",
description: "Expressive 16px rounding.",
},
];
export const sidebarStyles: {
value: SidebarStyle;
label: string;
description: string;
}[] = [
{
value: "floating",
label: "Floating",
description: "Inset navigation with rounded edges and elevation.",
},
{
value: "docked",
label: "Flush",
description: "Full-height navigation aligned to the viewport edge.",
},
];
export const colorThemes: {
value: ColorTheme;
label: string;
swatch: string;
}[] = [
{ value: "slate", label: "Slate", swatch: "hsl(240 5.9% 10%)" },
{ value: "blue", label: "Blue", swatch: "hsl(221.2 83.2% 53.3%)" },
{ value: "green", label: "Green", swatch: "hsl(142.1 76.2% 36.3%)" },
{ value: "rose", label: "Rose", swatch: "hsl(346.8 77.2% 49.8%)" },
{ value: "orange", label: "Orange", swatch: "hsl(24.6 95% 53.1%)" },
];
export const colorModes: {
value: ColorMode;
label: string;
description: string;
}[] = [
{ value: "system", label: "System", description: "Follow device setting." },
{ value: "light", label: "Light", description: "Always use light mode." },
{ value: "dark", label: "Dark", description: "Always use dark mode." },
{
value: "system",
label: "System",
description: "Match your device light or dark setting.",
},
{
value: "light",
label: "Light",
description: "Always use light mode.",
},
{
value: "dark",
label: "Dark",
description: "Always use dark mode.",
},
];
export const defaultInterfaceTheme: InterfaceTheme =
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? fallbackAppearance.interfaceTheme;
export const defaultFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_FONT ?? fallbackAppearance.fontPreference;
export const defaultBodyFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference;
export const defaultHeadingFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference;
export const defaultRadiusPreference: RadiusPreference =
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? fallbackAppearance.radiusPreference;
export const defaultSidebarStyle: SidebarStyle =
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? fallbackAppearance.sidebarStyle;
export const brand = {
name: env.NEXT_PUBLIC_BRAND_NAME ?? fallbackAppearance.brandName,
tagline: env.NEXT_PUBLIC_BRAND_TAGLINE ?? fallbackAppearance.brandTagline,
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? fallbackAppearance.brandLogoText,
icon: env.NEXT_PUBLIC_BRAND_ICON ?? fallbackAppearance.brandIcon,
name: env.NEXT_PUBLIC_BRAND_NAME ?? "beenvoice",
tagline:
env.NEXT_PUBLIC_BRAND_TAGLINE ??
"Simple and efficient invoicing for freelancers and small businesses",
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
};
+44
View File
@@ -0,0 +1,44 @@
const DATABASE_SETUP_HINT =
"Database not ready — run `bun db:migrate` (or `bun db:push` for local dev) after starting Postgres.";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function collectErrorParts(error: unknown): string[] {
const parts: string[] = [];
const seen = new Set<unknown>();
let current: unknown = error;
while (current && isRecord(current) && !seen.has(current)) {
seen.add(current);
if (typeof current.message === "string") {
parts.push(current.message);
}
if (typeof current.code === "string") {
parts.push(current.code);
}
current = current.cause;
}
return parts;
}
export function getDatabaseSetupErrorMessage(error: unknown): string | null {
const haystack = collectErrorParts(error).join(" ").toLowerCase();
if (
haystack.includes("does not exist") ||
haystack.includes("42p01") ||
haystack.includes("econnrefused") ||
haystack.includes("connection refused") ||
haystack.includes("connect econnrefused")
) {
return DATABASE_SETUP_HINT;
}
return null;
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { getAppUrl } from "~/lib/app-url";
interface InvoiceEmailTemplateProps {
invoice: {
invoiceNumber: string;
@@ -44,7 +46,7 @@ export function generateInvoiceEmailTemplate({
customMessage,
userName,
userEmail,
baseUrl: _baseUrl = "https://beenvoice.app",
baseUrl = getAppUrl(),
}: InvoiceEmailTemplateProps): { html: string; text: string } {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
@@ -1,4 +1,5 @@
import { formatEmailDate } from "src/lib/email-utils";
import { SUPPORT_EMAIL } from "~/lib/app-email";
interface PasswordResetEmailProps {
userEmail: string;
@@ -188,7 +189,7 @@ export function generatePasswordResetEmailTemplate({
</p>
<p>
beenvoice - Professional invoicing made simple<br>
<a href="mailto:support@beenvoice.com">support@beenvoice.com</a>
<a href="mailto:${SUPPORT_EMAIL}">${SUPPORT_EMAIL}</a>
</p>
</div>
</div>
@@ -213,7 +214,7 @@ SECURITY INFORMATION:
If you're having trouble with the link, copy and paste the entire URL into your browser's address bar.
If you have any questions or need assistance, please contact our support team at support@beenvoice.com.
If you have any questions or need assistance, please contact our support team at ${SUPPORT_EMAIL}.
Best regards,
The beenvoice Team
+6 -3
View File
@@ -1,5 +1,8 @@
import { getAppUrl } from "~/lib/app-url";
import { LEGAL_EMAIL, PRIVACY_EMAIL } from "~/lib/app-email";
export const LEGAL_LAST_UPDATED = "June 18, 2026";
export const LEGAL_PRIVACY_EMAIL = "privacy@soconnor.dev";
export const LEGAL_TERMS_EMAIL = "legal@soconnor.dev";
export const LEGAL_WEBSITE = "https://beenvoice.soconnor.dev";
export const LEGAL_PRIVACY_EMAIL = PRIVACY_EMAIL;
export const LEGAL_TERMS_EMAIL = LEGAL_EMAIL;
export const LEGAL_WEBSITE = getAppUrl();
+13
View File
@@ -32,6 +32,19 @@ export function isNavLinkActive(pathname: string, href: string): boolean {
return pathname === href;
}
const ADMIN_ONLY_HREFS = new Set(["/dashboard/administration"]);
export function getNavigationForUser(isAdmin: boolean): NavSection[] {
return navigationConfig
.map((section) => ({
...section,
links: section.links.filter(
(link) => isAdmin || !ADMIN_ONLY_HREFS.has(link.href),
),
}))
.filter((section) => section.links.length > 0);
}
export const navigationConfig: NavSection[] = [
{
title: "Main",
+19 -5
View File
@@ -15,6 +15,9 @@ const PLURALIZATION_RULES: Record<
tax: { singular: "Tax", plural: "Taxes" },
category: { singular: "Category", plural: "Categories" },
company: { singular: "Company", plural: "Companies" },
entity: { singular: "Entity", plural: "Entities" },
expense: { singular: "Expense", plural: "Expenses" },
report: { singular: "Report", plural: "Reports" },
};
/**
@@ -105,20 +108,31 @@ export function capitalize(word: string): string {
* Get a properly formatted label for a route segment
*/
export function getRouteLabel(segment: string, isPlural = true): string {
// First, check if it's already in our rules
const rule = PLURALIZATION_RULES[segment.toLowerCase()];
const lower = segment.toLowerCase();
// Route segments are often already plural (e.g. "entities", "invoices")
const ruleByPlural = Object.values(PLURALIZATION_RULES).find(
(r) => r.plural.toLowerCase() === lower,
);
if (ruleByPlural) {
return isPlural ? ruleByPlural.plural : ruleByPlural.singular;
}
const rule = PLURALIZATION_RULES[lower];
if (rule) {
return isPlural ? rule.plural : rule.singular;
}
// If not, try to find it by plural form
const singularForm = singularize(segment);
const singularRule = PLURALIZATION_RULES[singularForm.toLowerCase()];
if (singularRule) {
return isPlural ? singularRule.plural : singularRule.singular;
}
// Otherwise, just capitalize and optionally pluralize
const capitalized = capitalize(segment);
return isPlural ? pluralize(capitalized) : capitalized;
if (isPlural && /s$/i.test(segment)) {
return capitalized;
}
return isPlural ? pluralize(capitalized) : capitalize(singularForm);
}
+228 -139
View File
@@ -1,155 +1,244 @@
import { and, desc, eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices, clients } from "~/server/db/schema";
import { and, desc, eq, isNotNull, lte } from "drizzle-orm";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { clients, invoices } from "~/server/db/schema";
import type { StoredInvoiceStatus } from "~/types/invoice";
type LiteInvoice = {
id: string;
totalAmount: number;
status: string;
dueDate: Date;
issueDate: Date;
};
function buildRevenueMonthKeys(now: Date, count: number) {
const keys: string[] = [];
for (let i = count - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
keys.push(
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`,
);
}
return keys;
}
function aggregateDashboardMetrics(userInvoices: LiteInvoice[], now: Date) {
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
let totalRevenue = 0;
let pendingAmount = 0;
let overdueCount = 0;
let currentMonthRevenue = 0;
let lastMonthRevenue = 0;
const revenueByMonth = Object.fromEntries(
buildRevenueMonthKeys(now, 6).map((key) => [key, 0]),
) as Record<string, number>;
const statusTotals: Record<
string,
{ status: string; count: number; value: number }
> = {};
const monthlyTotals: Record<
string,
{
month: string;
totalInvoices: number;
paidInvoices: number;
pendingInvoices: number;
overdueInvoices: number;
draftInvoices: number;
}
> = {};
for (const inv of userInvoices) {
const effectiveStatus = getEffectiveInvoiceStatus(
inv.status as StoredInvoiceStatus,
inv.dueDate,
);
const amount = inv.totalAmount;
const issueDate = new Date(inv.issueDate);
if (effectiveStatus === "paid") {
totalRevenue += amount;
if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount;
} else if (
issueDate >= lastMonthStart &&
issueDate < currentMonthStart
) {
lastMonthRevenue += amount;
}
const revenueKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
const monthRevenue = revenueByMonth[revenueKey];
if (monthRevenue !== undefined) {
revenueByMonth[revenueKey] = monthRevenue + amount;
}
} else if (effectiveStatus === "sent" || effectiveStatus === "overdue") {
pendingAmount += amount;
}
if (effectiveStatus === "overdue") {
overdueCount++;
}
statusTotals[effectiveStatus] ??= {
status: effectiveStatus,
count: 0,
value: 0,
};
statusTotals[effectiveStatus].count += 1;
statusTotals[effectiveStatus].value += amount;
const monthKey = `${issueDate.getFullYear()}-${String(issueDate.getMonth() + 1).padStart(2, "0")}`;
monthlyTotals[monthKey] ??= {
month: monthKey,
totalInvoices: 0,
paidInvoices: 0,
pendingInvoices: 0,
overdueInvoices: 0,
draftInvoices: 0,
};
monthlyTotals[monthKey].totalInvoices += 1;
switch (effectiveStatus) {
case "paid":
monthlyTotals[monthKey].paidInvoices += 1;
break;
case "sent":
monthlyTotals[monthKey].pendingInvoices += 1;
break;
case "overdue":
monthlyTotals[monthKey].overdueInvoices += 1;
break;
case "draft":
monthlyTotals[monthKey].draftInvoices += 1;
break;
}
}
const revenueChartData = Object.entries(revenueByMonth)
.map(([month, revenue]) => ({
month,
revenue,
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}))
.sort((a, b) => a.month.localeCompare(b.month));
const statusChartData = Object.values(statusTotals).map((item) => ({
...item,
name: item.status.charAt(0).toUpperCase() + item.status.slice(1),
}));
const monthlyMetricsChartData = Object.values(monthlyTotals)
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-6)
.map((item) => ({
...item,
monthLabel: new Date(item.month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}));
return {
totalRevenue,
pendingAmount,
overdueCount,
revenueChange:
lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0,
revenueChartData,
statusChartData,
monthlyMetricsChartData,
};
}
export const dashboardRouter = createTRPCRouter({
getStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
const now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
// 1. Fetch all invoices for the user to calculate stats
// Note: For very large datasets, we should use separate count/sum queries,
// but for typical usage, fetching fields is fine and allows flexible JS calculation
// where SQL complexity might be high (e.g. dynamic status).
// However, let's try to be efficient with SQL where possible.
const userInvoices = await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
columns: {
id: true,
totalAmount: true,
status: true,
dueDate: true,
issueDate: true,
},
});
const userClientsCount = await ctx.db.$count(
clients,
eq(clients.createdById, userId),
);
// Helper to check status
const getStatus = (inv: (typeof userInvoices)[0]) => {
if (inv.status === "paid") return "paid";
if (inv.status === "draft") return "draft";
if (new Date(inv.dueDate) < now && inv.status !== "paid")
return "overdue";
return "sent";
};
// Calculate Stats
let totalRevenue = 0;
let pendingAmount = 0;
let overdueCount = 0;
let currentMonthRevenue = 0;
let lastMonthRevenue = 0;
for (const inv of userInvoices) {
const status = getStatus(inv);
const amount = inv.totalAmount;
const issueDate = new Date(inv.issueDate);
if (status === "paid") {
totalRevenue += amount;
if (issueDate >= currentMonthStart) {
currentMonthRevenue += amount;
} else if (
issueDate >= lastMonthStart &&
issueDate < currentMonthStart
) {
lastMonthRevenue += amount;
}
} else if (status === "sent" || status === "overdue") {
pendingAmount += amount;
}
if (status === "overdue") {
overdueCount++;
}
}
// Revenue Trend (Last 6 months)
const revenueByMonth: Record<string, number> = {};
for (let i = 0; i < 6; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
revenueByMonth[key] = 0;
}
for (const inv of userInvoices) {
if (getStatus(inv) === "paid") {
const d = new Date(inv.issueDate);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
if (revenueByMonth[key] !== undefined) {
revenueByMonth[key] += inv.totalAmount;
}
}
}
const revenueChartData = Object.entries(revenueByMonth)
.map(([month, revenue]) => ({
month,
revenue,
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
month: "short",
year: "2-digit",
}),
}))
.sort((a, b) => a.month.localeCompare(b.month));
// Recent Activity
const recentInvoices = await ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
limit: 5,
with: {
client: {
columns: { name: true },
const [
userInvoices,
userClientsCount,
recentInvoices,
currentDraft,
] = await Promise.all([
ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
columns: {
id: true,
totalAmount: true,
status: true,
dueDate: true,
issueDate: true,
},
},
});
}),
ctx.db.$count(clients, eq(clients.createdById, userId)),
ctx.db.query.invoices.findMany({
where: eq(invoices.createdById, userId),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
limit: 5,
with: {
client: {
columns: { name: true },
},
},
}),
ctx.db.query.invoices.findFirst({
where: and(
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
),
orderBy: [
desc(invoices.issueDate),
desc(invoices.dueDate),
desc(invoices.invoiceNumber),
],
columns: {
id: true,
invoiceNumber: true,
totalAmount: true,
},
with: {
client: { columns: { name: true } },
items: { columns: { hours: true } },
},
}),
]);
const sendReminderDue = await ctx.db.query.invoices.findMany({
where: and(
eq(invoices.createdById, userId),
eq(invoices.status, "draft"),
isNotNull(invoices.sendReminderAt),
lte(invoices.sendReminderAt, now),
),
columns: {
id: true,
invoiceNumber: true,
invoicePrefix: true,
sendReminderAt: true,
},
with: {
client: { columns: { name: true } },
},
orderBy: [desc(invoices.sendReminderAt)],
limit: 10,
});
const metrics = aggregateDashboardMetrics(userInvoices, now);
return {
totalRevenue,
pendingAmount,
overdueCount,
...metrics,
totalClients: userClientsCount,
revenueChange:
lastMonthRevenue > 0
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
: 0,
revenueChartData,
recentInvoices,
sendReminderDue,
currentDraft: currentDraft
? {
id: currentDraft.id,
invoiceNumber: currentDraft.invoiceNumber,
totalAmount: currentDraft.totalAmount,
client: currentDraft.client,
totalHours: currentDraft.items.reduce(
(sum, item) => sum + item.hours,
0,
),
}
: null,
};
}),
});
+4 -2
View File
@@ -4,6 +4,8 @@ import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm";
import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { getAppUrl } from "~/lib/app-url";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
@@ -153,7 +155,7 @@ export const emailRouter = createTRPCRouter({
customMessage,
userName,
userEmail,
baseUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
baseUrl: getAppUrl(),
});
// Determine Resend instance and email configuration to use
@@ -177,7 +179,7 @@ export const emailRouter = createTRPCRouter({
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? "noreply@example.com";
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
} else {
throw new Error(
"Email delivery is not configured. Add a Resend API key globally or on this business.",
+2 -1
View File
@@ -12,6 +12,7 @@ import { TRPCError } from "@trpc/server";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { Resend } from "resend";
import { env } from "~/env";
import { NOREPLY_EMAIL } from "~/lib/app-email";
import { generateReminderEmailTemplate } from "~/lib/email-templates/reminder-email";
import type { db } from "~/server/db";
@@ -844,7 +845,7 @@ export const invoicesRouter = createTRPCRouter({
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
} else if (env.RESEND_API_KEY) {
resendInstance = new Resend(env.RESEND_API_KEY);
fromEmail = invoice.business?.email ?? "noreply@example.com";
fromEmail = invoice.business?.email ?? NOREPLY_EMAIL;
} else {
throw new TRPCError({
code: "BAD_REQUEST",
File diff suppressed because it is too large Load Diff
+3 -8
View File
@@ -133,18 +133,13 @@ export const createTRPCRouter = t.router;
*/
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
const result = await next();
const end = Date.now();
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
}
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
return result;
});
+1 -25
View File
@@ -32,37 +32,13 @@ export const users = createTable("user", (d) => ({
// Custom fields
prefersReducedMotion: d.boolean().default(false).notNull(),
animationSpeedMultiplier: d.real().default(1).notNull(),
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
customColor: d.varchar({ length: 50 }),
theme: d.varchar({ length: 20 }).default("system").notNull(),
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
fontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
role: d.varchar({ length: 20 }).default("user").notNull(),
onboardingCompletedAt: d.timestamp(),
}));
export const platformSettings = createTable("platform_setting", (d) => ({
id: d.varchar({ length: 50 }).notNull().primaryKey().default("global"),
brandName: d.varchar({ length: 100 }).default("beenvoice").notNull(),
brandTagline: d
.varchar({ length: 255 })
.default(
"Simple and efficient invoicing for freelancers and small businesses",
)
.notNull(),
brandLogoText: d.varchar({ length: 100 }).default("beenvoice").notNull(),
brandIcon: d.varchar({ length: 20 }).default("$").notNull(),
colorTheme: d.varchar({ length: 50 }).default("slate").notNull(),
customColor: d.varchar({ length: 50 }),
theme: d.varchar({ length: 20 }).default("system").notNull(),
interfaceTheme: d.varchar({ length: 50 }).default("beenvoice").notNull(),
bodyFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
headingFontPreference: d.varchar({ length: 50 }).default("brand").notNull(),
radiusPreference: d.varchar({ length: 20 }).default("xl").notNull(),
sidebarStyle: d.varchar({ length: 20 }).default("floating").notNull(),
pdfTemplate: d.varchar({ length: 20 }).default("classic").notNull(),
pdfAccentColor: d.varchar({ length: 50 }).default("#111827").notNull(),
pdfFooterText: d
+11 -577
View File
@@ -3,218 +3,35 @@
@layer base {
:root {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
--background: 0 0% 100%;
/* #FFFFFF */
--foreground: 240 10% 3.9%;
/* #09090B */
--card: 0 0% 100%;
/* #FFFFFF */
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
/* #18181B */
--primary-foreground: 0 0% 98%;
/* #FAFAFA */
--secondary: 240 4.8% 90%;
/* #E4E4E7 (Darkened for contrast) */
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
/* #F4F4F5 */
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
/* #E4E4E7 */
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 1rem;
/* 16px Global Radius */
}
:root[data-interface-theme="shadcn"] {
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--radius: 0.5rem;
}
:root[data-interface-theme="beenvoice"] {
--secondary: 240 4.8% 90%;
--secondary-foreground: 240 5.9% 10%;
--radius: 1rem;
}
:root[data-interface-theme="frutiger"] {
--background: 0 0% 100%;
--foreground: 210 24% 8%;
--card: 0 0% 100%;
--card-foreground: 210 24% 8%;
--popover: 0 0% 100%;
--popover-foreground: 210 24% 8%;
--primary: 203 100% 18%;
--primary-foreground: 46 100% 91%;
--secondary: 47 100% 50%;
--secondary-foreground: 210 24% 8%;
--muted: 199 73% 91%;
--muted-foreground: 203 52% 24%;
--accent: 47 100% 50%;
--accent-foreground: 210 24% 8%;
--border: 203 45% 42%;
--input: 203 45% 42%;
--ring: 203 100% 18%;
--radius: 0rem;
}
:root[data-interface-theme="frutiger-aero"] {
--background: 190 76% 96%;
--foreground: 205 50% 12%;
--card: 0 0% 100%;
--card-foreground: 205 50% 12%;
--popover: 0 0% 100%;
--popover-foreground: 205 50% 12%;
--primary: 201 100% 37%;
--primary-foreground: 0 0% 100%;
--secondary: 104 55% 55%;
--secondary-foreground: 205 50% 12%;
--muted: 190 56% 90%;
--muted-foreground: 205 32% 32%;
--accent: 104 55% 55%;
--accent-foreground: 205 50% 12%;
--border: 195 48% 74%;
--input: 195 48% 74%;
--ring: 201 100% 37%;
--radius: 0.75rem;
}
:root[data-interface-theme="minimal"] {
--background: 0 0% 100%;
--card: 0 0% 100%;
--popover: 0 0% 100%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 96.5%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 97%;
--accent: 240 4.8% 96%;
--accent-foreground: 240 5.9% 10%;
}
:root[data-interface-theme="editorial"] {
--background: 36 33% 98%;
--card: 36 33% 99%;
--popover: 36 33% 99%;
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 30 18% 91%;
--secondary-foreground: 24 10% 10%;
--muted: 30 20% 94%;
--accent: 346.8 77.2% 49.8%;
--accent-foreground: 355.7 100% 97.3%;
--border: 30 15% 86%;
--input: 30 15% 86%;
}
:root[data-body-font="brand"],
:root[data-body-font="inter"] {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
:root[data-body-font="frutiger"] {
--app-font-sans: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
}
:root[data-body-font="platform"] {
--app-font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-body-font="serif"] {
--app-font-sans:
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
}
:root[data-heading-font="brand"],
:root[data-heading-font="serif"] {
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-heading-font="frutiger"] {
--app-font-heading: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
}
:root[data-heading-font="platform"] {
--app-font-heading:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-heading-font="inter"] {
--app-font-heading: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="brand"]:not([data-body-font]) {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-font="frutiger"]:not([data-body-font]) {
--app-font-sans: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-frutiger), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="platform"]:not([data-body-font]) {
--app-font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
--app-font-heading:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
:root[data-font="inter"]:not([data-body-font]) {
--app-font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="serif"]:not([data-body-font]) {
--app-font-sans:
ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, serif;
}
:root[data-radius="none"] {
--radius: 0rem;
}
:root[data-radius="sm"] {
--radius: 0.25rem;
}
:root[data-radius="md"] {
--radius: 0.5rem;
}
:root[data-radius="lg"] {
--radius: 0.75rem;
}
:root[data-radius="xl"] {
--radius: 1rem;
}
:root[data-color-mode="dark"],
:root.dark {
--background: 240 10% 3.9%;
/* #09090B */
--foreground: 0 0% 98%;
/* #FAFAFA */
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
@@ -222,7 +39,6 @@
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 20%;
/* #27272A */
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
@@ -231,7 +47,6 @@
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
/* #27272A */
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
@@ -239,9 +54,7 @@
@media (prefers-color-scheme: dark) {
:root:not([data-color-mode="light"]) {
--background: 240 10% 3.9%;
/* #09090B */
--foreground: 0 0% 98%;
/* #FAFAFA */
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
@@ -249,7 +62,6 @@
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 20%;
/* #27272A */
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
@@ -258,110 +70,10 @@
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
/* #27272A */
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
:root[data-color-theme="slate"] {
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
}
:root[data-color-theme="blue"] {
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--accent: 217.2 91.2% 59.8%;
--accent-foreground: 210 40% 98%;
}
:root[data-color-theme="green"] {
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--accent: 142.1 70.6% 45.3%;
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-color-theme="rose"] {
--primary: 346.8 77.2% 49.8%;
--primary-foreground: 355.7 100% 97.3%;
--accent: 346.8 77.2% 49.8%;
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-color-theme="orange"] {
--primary: 24.6 95% 53.1%;
--primary-foreground: 60 9.1% 97.8%;
--accent: 20.5 90.2% 48.2%;
--accent-foreground: 60 9.1% 97.8%;
}
:root[data-color-theme="custom"] {
--primary: var(--custom-primary, 142.1 76.2% 36.3%);
--primary-foreground: 355.7 100% 97.3%;
--accent: var(--custom-primary, 142.1 76.2% 36.3%);
--accent-foreground: 355.7 100% 97.3%;
}
:root[data-interface-theme="frutiger"] {
--background: 0 0% 100%;
--foreground: 210 24% 8%;
--card: 0 0% 100%;
--card-foreground: 210 24% 8%;
--popover: 0 0% 100%;
--popover-foreground: 210 24% 8%;
--primary: 203 100% 18%;
--primary-foreground: 46 100% 91%;
--accent: 47 100% 50%;
--accent-foreground: 210 24% 8%;
--secondary: 47 100% 50%;
--secondary-foreground: 210 24% 8%;
--muted: 199 73% 91%;
--muted-foreground: 203 52% 24%;
--border: 203 45% 42%;
--input: 203 45% 42%;
--ring: 203 100% 18%;
}
:root[data-interface-theme="frutiger-aero"] {
--background: 190 76% 96%;
--foreground: 205 50% 12%;
--card: 0 0% 100%;
--card-foreground: 205 50% 12%;
--popover: 0 0% 100%;
--popover-foreground: 205 50% 12%;
--primary: 201 100% 37%;
--primary-foreground: 0 0% 100%;
--accent: 104 55% 55%;
--accent-foreground: 205 50% 12%;
--secondary: 104 55% 55%;
--secondary-foreground: 205 50% 12%;
--muted: 190 56% 90%;
--muted-foreground: 205 32% 32%;
--border: 195 48% 74%;
--input: 195 48% 74%;
--ring: 201 100% 37%;
}
:root[data-color-mode="dark"][data-color-theme="slate"],
:root.dark[data-color-theme="slate"] {
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
}
@media (prefers-color-scheme: dark) {
:root:not([data-color-mode="light"])[data-color-theme="slate"] {
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
}
}
}
@theme inline {
@@ -415,298 +127,39 @@
}
@layer utilities {
:root[data-interface-theme="shadcn"] .brand-background,
:root[data-interface-theme="minimal"] .brand-background {
display: none;
}
:root[data-interface-theme="frutiger"] .brand-background {
display: none;
}
:root[data-interface-theme="frutiger-aero"] .brand-background {
display: flex;
background:
radial-gradient(circle at 18% 22%, hsl(104 55% 55% / 0.35), transparent 32%),
radial-gradient(circle at 82% 18%, hsl(190 88% 66% / 0.42), transparent 30%),
linear-gradient(180deg, hsl(195 100% 94% / 0.95), hsl(0 0% 100% / 0.35));
}
:root[data-interface-theme="frutiger"] .dashboard-content-shell {
.dashboard-content-shell {
padding: 1rem;
padding-top: 4rem;
}
@media (min-width: 768px) {
:root[data-interface-theme="frutiger"] .dashboard-content-shell {
.dashboard-content-shell {
padding: 1.25rem;
padding-top: 1.25rem;
}
}
:root[data-interface-theme="frutiger"] .bg-dashboard {
background: hsl(var(--background));
}
:root[data-interface-theme="frutiger-aero"] .bg-dashboard {
background:
radial-gradient(circle at 78% 8%, hsl(104 55% 55% / 0.22), transparent 26rem),
linear-gradient(180deg, hsl(190 76% 96%) 0%, hsl(0 0% 100%) 72%);
}
:root[data-interface-theme="frutiger"] aside {
border-color: hsl(var(--primary));
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
:root[data-interface-theme="frutiger"] aside [class*="text-muted-foreground"] {
color: hsl(var(--primary-foreground) / 0.72);
}
:root[data-interface-theme="frutiger"] aside a {
color: hsl(var(--primary-foreground) / 0.86);
}
:root[data-interface-theme="frutiger"] aside a:hover,
:root[data-interface-theme="frutiger"] aside a[data-active="true"] {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
:root[data-interface-theme="frutiger"] aside button {
color: hsl(var(--primary-foreground) / 0.82);
}
:root[data-interface-theme="frutiger"] .dashboard-mobile-header {
border-color: hsl(var(--primary));
background-color: hsl(var(--accent));
color: hsl(var(--foreground));
backdrop-filter: none;
}
:root[data-interface-theme="frutiger"] .platform-header-surface {
border-color: hsl(var(--primary));
border-top: 0.5rem solid hsl(var(--primary));
background-color: hsl(var(--accent));
color: hsl(var(--foreground));
box-shadow: none;
}
:root[data-interface-theme="frutiger"] .platform-header-gradient {
display: none !important;
background: none !important;
}
:root[data-interface-theme="frutiger"] .platform-header-surface .text-primary,
:root[data-interface-theme="frutiger"] .platform-header-surface [class*="text-primary"],
:root[data-interface-theme="frutiger"] .dashboard-mobile-header .text-primary,
:root[data-interface-theme="frutiger"] .dashboard-mobile-header [class*="text-primary"] {
color: hsl(var(--foreground));
}
:root[data-interface-theme="frutiger"] .platform-header-surface .text-muted-foreground,
:root[data-interface-theme="frutiger"] .platform-header-surface [class*="text-muted-foreground"] {
color: hsl(var(--foreground) / 0.72);
}
:root[data-interface-theme="frutiger-aero"] aside {
border-color: hsl(190 88% 66% / 0.55);
background:
linear-gradient(180deg, hsl(201 100% 37% / 0.82), hsl(190 88% 45% / 0.72)),
hsl(201 100% 37% / 0.78);
color: hsl(var(--primary-foreground));
box-shadow: 0 18px 36px -20px hsl(201 100% 22% / 0.55);
backdrop-filter: blur(18px) saturate(1.35);
}
:root[data-interface-theme="frutiger-aero"] aside [class*="text-muted-foreground"] {
color: hsl(var(--primary-foreground) / 0.76);
}
:root[data-interface-theme="frutiger-aero"] aside a {
color: hsl(var(--primary-foreground) / 0.88);
}
:root[data-interface-theme="frutiger-aero"] aside a:hover,
:root[data-interface-theme="frutiger-aero"] aside a[data-active="true"] {
background-color: hsl(0 0% 100% / 0.92);
color: hsl(var(--primary));
}
:root[data-interface-theme="frutiger-aero"] .dashboard-mobile-header {
border-color: hsl(190 88% 66% / 0.55);
background-color: hsl(0 0% 100% / 0.78);
color: hsl(var(--foreground));
backdrop-filter: blur(18px) saturate(1.35);
}
:root[data-interface-theme="frutiger-aero"] .platform-header-surface {
border-color: hsl(190 88% 66% / 0.6);
background:
linear-gradient(180deg, hsl(0 0% 100% / 0.88), hsl(190 88% 92% / 0.72)),
hsl(0 0% 100% / 0.72);
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.9),
0 18px 36px -28px hsl(201 100% 37% / 0.6);
backdrop-filter: blur(16px) saturate(1.35);
}
:root[data-interface-theme="frutiger-aero"] .platform-header-gradient {
display: block;
background: radial-gradient(circle at 92% 10%, hsl(104 55% 55% / 0.45), transparent 34%),
linear-gradient(135deg, hsl(190 88% 66% / 0.28), transparent 48%);
opacity: 1;
}
:root[data-interface-theme="frutiger"] [data-slot="card"],
:root[data-interface-theme="frutiger"] [data-slot="dialog-content"],
:root[data-interface-theme="frutiger"] [data-slot="popover-content"],
:root[data-interface-theme="frutiger"] [data-slot="select-content"] {
border-color: hsl(var(--primary) / 0.45);
border-radius: 0;
background-color: hsl(var(--card));
box-shadow: none;
}
:root[data-interface-theme="frutiger"] [data-slot="card"] {
border-top: 0.35rem solid hsl(var(--accent));
}
:root[data-interface-theme="frutiger-aero"] [data-slot="card"] {
border-color: hsl(190 88% 66% / 0.45);
background-color: hsl(0 0% 100% / 0.78);
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.9),
0 16px 34px -30px hsl(201 100% 37% / 0.7);
backdrop-filter: blur(14px) saturate(1.25);
}
:root[data-interface-theme="frutiger"] [data-slot="button"],
:root[data-interface-theme="frutiger"] button,
:root[data-interface-theme="frutiger"] input,
:root[data-interface-theme="frutiger"] textarea,
:root[data-interface-theme="frutiger"] [role="combobox"] {
border-radius: 0;
}
:root[data-interface-theme="frutiger"] .button-hover:hover,
:root[data-interface-theme="frutiger"] .card-hover:hover {
transform: none;
box-shadow: none;
}
:root[data-interface-theme="frutiger"] [data-slot="card-header"],
:root[data-interface-theme="frutiger"] [data-slot="card-content"],
:root[data-interface-theme="frutiger"] [data-slot="card-footer"] {
padding-inline: 1rem;
}
:root[data-interface-theme="minimal"] [data-slot="card"] {
background-color: transparent;
border-color: transparent;
border-radius: 0;
border-top-color: hsl(var(--border));
box-shadow: none;
backdrop-filter: none;
overflow: visible;
}
:root[data-interface-theme="minimal"] [data-slot="card"] + [data-slot="card"],
:root[data-interface-theme="minimal"] .form-section + .form-section {
border-top: 1px solid hsl(var(--border));
padding-top: 1rem;
}
:root[data-interface-theme="minimal"] [data-slot="card-header"],
:root[data-interface-theme="minimal"] [data-slot="card-content"],
:root[data-interface-theme="minimal"] [data-slot="card-footer"] {
padding-inline: 0;
}
:root[data-interface-theme="minimal"] [data-slot="card-header"] {
padding-top: 0.75rem;
padding-bottom: 0.5rem;
}
:root[data-interface-theme="minimal"] [data-slot="card-content"] {
padding-bottom: 0.75rem;
}
:root[data-interface-theme="minimal"] .page-enter,
:root[data-interface-theme="minimal"] [class*="space-y-8"],
:root[data-interface-theme="minimal"] [class*="space-y-6"] {
row-gap: 1rem;
}
:root[data-interface-theme="minimal"]
[class*="space-y-8"]
> :not([hidden])
~ :not([hidden]),
:root[data-interface-theme="minimal"]
[class*="space-y-6"]
> :not([hidden])
~ :not([hidden]) {
margin-top: 1rem;
}
:root[data-interface-theme="minimal"] [class*="gap-6"] {
gap: 1rem;
}
:root[data-interface-theme="minimal"] .platform-header-surface {
background-color: transparent;
border-color: transparent;
box-shadow: none;
backdrop-filter: none;
overflow: visible;
}
:root[data-interface-theme="minimal"] .platform-header-content {
padding: 0;
}
:root[data-interface-theme="minimal"] .platform-header-gradient {
display: none;
}
:root[data-interface-theme="minimal"] .bg-dashboard {
background-color: hsl(var(--background));
}
:root[data-interface-theme="beenvoice"] .dashboard-content-shell {
padding: 0.75rem;
padding-top: 4rem;
}
@media (min-width: 768px) {
:root[data-interface-theme="beenvoice"] .dashboard-content-shell {
padding-top: 0.75rem;
}
}
:root[data-interface-theme="beenvoice"] .platform-header-content {
.platform-header-content {
padding: 1.25rem;
}
:root[data-interface-theme="beenvoice"] [data-slot="card"] {
[data-slot="card"] {
border-radius: var(--radius-lg);
}
:root[data-interface-theme="beenvoice"] [data-slot="card-header"] {
[data-slot="card-header"] {
padding: 1rem 1rem 0.75rem;
}
:root[data-interface-theme="beenvoice"] [data-slot="card-content"] {
[data-slot="card-content"] {
padding-inline: 1rem;
padding-bottom: 1rem;
}
:root[data-interface-theme="beenvoice"] [data-slot="card-footer"] {
[data-slot="card-footer"] {
padding: 1rem;
}
:root[data-interface-theme="editorial"] .brand-background {
opacity: 0.55;
}
.animate-blob {
animation: blob 7s infinite;
}
@@ -728,25 +181,6 @@
transform: translateY(-2px);
box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1);
}
:root[data-radius] .rounded-sm {
border-radius: var(--radius-sm);
}
:root[data-radius] .rounded,
:root[data-radius] .rounded-md {
border-radius: var(--radius-md);
}
:root[data-radius] .rounded-lg {
border-radius: var(--radius-lg);
}
:root[data-radius] .rounded-xl,
:root[data-radius] .rounded-2xl,
:root[data-radius] .rounded-3xl {
border-radius: var(--radius-xl);
}
}
@keyframes blob {
+2 -3
View File
@@ -8,6 +8,7 @@ import { useState } from "react";
import SuperJSON from "superjson";
import { createQueryClient } from "./query-client";
import { getAppUrl } from "~/lib/app-url";
import type { AppRouter } from "~/server/api/root";
let clientQueryClientSingleton: QueryClient | undefined = undefined;
@@ -72,7 +73,5 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
}
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.NEXT_PUBLIC_APP_URL) return process.env.NEXT_PUBLIC_APP_URL;
return `http://localhost:${process.env.PORT ?? 3000}`;
return getAppUrl();
}