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:
+1
-15
@@ -85,27 +85,13 @@ POSTGRES_DB=postgres
|
||||
# White-label defaults (optional)
|
||||
# =============================================================================
|
||||
# Baked in at Docker build. After first deploy, admins can override many of
|
||||
# these from Settings → Appearance in the dashboard.
|
||||
# Optional white-label defaults (build-time). Users choose light/dark in Settings.
|
||||
|
||||
NEXT_PUBLIC_BRAND_NAME=beenvoice
|
||||
NEXT_PUBLIC_BRAND_TAGLINE=Simple and efficient invoicing for freelancers and small businesses
|
||||
NEXT_PUBLIC_BRAND_LOGO_TEXT=beenvoice
|
||||
NEXT_PUBLIC_BRAND_ICON=$
|
||||
|
||||
# Interface theme: beenvoice | frutiger | frutiger-aero | shadcn | minimal | editorial
|
||||
NEXT_PUBLIC_DEFAULT_INTERFACE_THEME=beenvoice
|
||||
|
||||
# Font prefs: brand | frutiger | platform | inter | serif
|
||||
NEXT_PUBLIC_DEFAULT_FONT=brand
|
||||
NEXT_PUBLIC_DEFAULT_BODY_FONT=brand
|
||||
NEXT_PUBLIC_DEFAULT_HEADING_FONT=brand
|
||||
|
||||
# Corner radius: none | sm | md | lg | xl
|
||||
NEXT_PUBLIC_DEFAULT_RADIUS=xl
|
||||
|
||||
# Sidebar chrome: floating | docked
|
||||
NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE=floating
|
||||
|
||||
# =============================================================================
|
||||
# Email — Resend (optional)
|
||||
# =============================================================================
|
||||
|
||||
@@ -73,6 +73,8 @@ Start Postgres (dev compose exposes port 5432):
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
After a fresh volume (`docker compose down -v`), Postgres starts empty — you must apply schema before registering or signing in.
|
||||
|
||||
Apply schema (pick one):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "colorTheme";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "customColor";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "interfaceTheme";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "fontPreference";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "bodyFontPreference";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "headingFontPreference";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "radiusPreference";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_user" DROP COLUMN IF EXISTS "sidebarStyle";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandName";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandTagline";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandLogoText";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "brandIcon";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "colorTheme";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "customColor";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "theme";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "interfaceTheme";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "bodyFontPreference";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "headingFontPreference";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "radiusPreference";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_platform_setting" DROP COLUMN IF EXISTS "sidebarStyle";
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "beenvoice_user" ADD COLUMN IF NOT EXISTS "onboardingCompletedAt" timestamp;
|
||||
|
||||
-- Users who already have a business are treated as onboarded
|
||||
UPDATE "beenvoice_user" u
|
||||
SET "onboardingCompletedAt" = COALESCE(u."onboardingCompletedAt", NOW())
|
||||
WHERE u."onboardingCompletedAt" IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "beenvoice_business" b
|
||||
WHERE b."createdById" = u."id"
|
||||
);
|
||||
@@ -120,6 +120,20 @@
|
||||
"when": 1781500000000,
|
||||
"tag": "0016_fix_send_reminder_at_column",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1781600000000,
|
||||
"tag": "0017_drop_theme_engine_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1781700000000,
|
||||
"tag": "0018_user_onboarding",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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,24 +86,119 @@ export function ActiveTimerWidget() {
|
||||
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||
: null;
|
||||
|
||||
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 || (
|
||||
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>
|
||||
<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-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{" "}
|
||||
@@ -106,27 +218,21 @@ export function ActiveTimerWidget() {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-primary font-mono text-2xl font-bold tabular-nums">
|
||||
<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 gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<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.5 h-3.5 w-3.5" />
|
||||
<Clock className="mr-1 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>
|
||||
{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>
|
||||
<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="flex items-center space-x-1 text-xs"
|
||||
style={{
|
||||
color: isNeutral
|
||||
? "hsl(var(--muted-foreground))"
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs font-medium",
|
||||
isNeutral
|
||||
? "text-muted-foreground"
|
||||
: isPositive
|
||||
? "oklch(var(--chart-2))"
|
||||
: "oklch(var(--chart-3))",
|
||||
}}
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-amber-600 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<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%">
|
||||
<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,8 +87,7 @@ export function RevenueChart({ data }: RevenueChartProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-48 w-full md:h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveChart height={256} className="h-48 md:h-64">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,22 +346,21 @@ 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">
|
||||
<PageTabsContent value="compose">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -387,9 +396,9 @@ export default function SendEmailPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
<TabsContent value="preview" className="space-y-6">
|
||||
<PageTabsContent value="preview">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+166
-284
@@ -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">
|
||||
<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'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
|
||||
Create invoice
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
<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">
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover-lift flex-1"
|
||||
>
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}`}>
|
||||
<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="hover-lift flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
||||
<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>
|
||||
</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">
|
||||
<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 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">
|
||||
<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>
|
||||
<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">
|
||||
<span className="shrink-0 font-mono text-sm font-medium tabular-nums">
|
||||
${invoice.totalAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{new Date(invoice.issueDate).toLocaleDateString()}
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{invoice.client?.name}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</DashboardGrid>
|
||||
</DashboardPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,152 +646,14 @@ 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.
|
||||
</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">
|
||||
<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
|
||||
Color mode
|
||||
</Label>
|
||||
<Select
|
||||
value={colorMode}
|
||||
@@ -911,271 +685,10 @@ export function SettingsContent() {
|
||||
}
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {}
|
||||
`,
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
{!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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
const stored = readStoredColorMode();
|
||||
if (stored) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
|
||||
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 (
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
@@ -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
@@ -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,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
@@ -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 ?? "$",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,59 +1,68 @@
|
||||
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";
|
||||
|
||||
export const dashboardRouter = createTRPCRouter({
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
const now = new Date();
|
||||
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);
|
||||
|
||||
// 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;
|
||||
|
||||
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 status = getStatus(inv);
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(
|
||||
inv.status as StoredInvoiceStatus,
|
||||
inv.dueDate,
|
||||
);
|
||||
const amount = inv.totalAmount;
|
||||
const issueDate = new Date(inv.issueDate);
|
||||
|
||||
if (status === "paid") {
|
||||
if (effectiveStatus === "paid") {
|
||||
totalRevenue += amount;
|
||||
|
||||
if (issueDate >= currentMonthStart) {
|
||||
@@ -64,30 +73,52 @@ export const dashboardRouter = createTRPCRouter({
|
||||
) {
|
||||
lastMonthRevenue += amount;
|
||||
}
|
||||
} else if (status === "sent" || status === "overdue") {
|
||||
|
||||
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 (status === "overdue") {
|
||||
if (effectiveStatus === "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;
|
||||
}
|
||||
statusTotals[effectiveStatus] ??= {
|
||||
status: effectiveStatus,
|
||||
count: 0,
|
||||
value: 0,
|
||||
};
|
||||
statusTotals[effectiveStatus].count += 1;
|
||||
statusTotals[effectiveStatus].value += amount;
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +133,59 @@ export const dashboardRouter = createTRPCRouter({
|
||||
}))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
// Recent Activity
|
||||
const recentInvoices = await ctx.db.query.invoices.findMany({
|
||||
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 [
|
||||
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),
|
||||
@@ -116,40 +198,47 @@ export const dashboardRouter = createTRPCRouter({
|
||||
columns: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sendReminderDue = await ctx.db.query.invoices.findMany({
|
||||
}),
|
||||
ctx.db.query.invoices.findFirst({
|
||||
where: and(
|
||||
eq(invoices.createdById, userId),
|
||||
eq(invoices.status, "draft"),
|
||||
isNotNull(invoices.sendReminderAt),
|
||||
lte(invoices.sendReminderAt, now),
|
||||
),
|
||||
orderBy: [
|
||||
desc(invoices.issueDate),
|
||||
desc(invoices.dueDate),
|
||||
desc(invoices.invoiceNumber),
|
||||
],
|
||||
columns: {
|
||||
id: true,
|
||||
invoiceNumber: true,
|
||||
invoicePrefix: true,
|
||||
sendReminderAt: true,
|
||||
totalAmount: true,
|
||||
},
|
||||
with: {
|
||||
client: { columns: { name: true } },
|
||||
items: { columns: { hours: 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,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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+517
-148
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import {
|
||||
@@ -14,29 +14,20 @@ import {
|
||||
businesses,
|
||||
invoices,
|
||||
invoiceItems,
|
||||
invoicePayments,
|
||||
invoiceTemplates,
|
||||
expenses,
|
||||
recurringInvoices,
|
||||
recurringInvoiceItems,
|
||||
timeEntries,
|
||||
platformSettings,
|
||||
} from "~/server/db/schema";
|
||||
import {
|
||||
colorModeSchema,
|
||||
colorThemeSchema,
|
||||
defaultBodyFontPreference,
|
||||
defaultHeadingFontPreference,
|
||||
defaultInterfaceTheme,
|
||||
defaultRadiusPreference,
|
||||
defaultSidebarStyle,
|
||||
fallbackAppearance,
|
||||
fontPreferenceSchema,
|
||||
hslChannelsSchema,
|
||||
interfaceThemeSchema,
|
||||
defaultColorMode,
|
||||
defaultPdfSettings,
|
||||
pdfTemplateSchema,
|
||||
radiusPreferenceSchema,
|
||||
sidebarStyleSchema,
|
||||
type ColorMode,
|
||||
type ColorTheme,
|
||||
type FontPreference,
|
||||
type InterfaceTheme,
|
||||
type RadiusPreference,
|
||||
type SidebarStyle,
|
||||
} from "~/lib/branding";
|
||||
import type { db as database } from "~/server/db";
|
||||
|
||||
@@ -54,6 +45,24 @@ async function requireAdmin(ctx: {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBusinessId(
|
||||
refs: { businessName?: string; businessNickname?: string },
|
||||
businessIdMap: Map<string, string>,
|
||||
): string | null {
|
||||
if (refs.businessNickname) {
|
||||
const byNickname = businessIdMap.get(refs.businessNickname);
|
||||
if (byNickname) return byNickname;
|
||||
}
|
||||
if (refs.businessName) {
|
||||
return businessIdMap.get(refs.businessName) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function backupImportError(message: string): never {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message });
|
||||
}
|
||||
|
||||
// Validation schemas for backup data
|
||||
const ClientBackupSchema = z.object({
|
||||
name: z.string(),
|
||||
@@ -65,6 +74,8 @@ const ClientBackupSchema = z.object({
|
||||
state: z.string().optional(),
|
||||
postalCode: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
defaultHourlyRate: z.number().optional(),
|
||||
currency: z.string().default("USD"),
|
||||
});
|
||||
|
||||
const BusinessBackupSchema = z.object({
|
||||
@@ -82,10 +93,13 @@ const BusinessBackupSchema = z.object({
|
||||
taxId: z.string().optional(),
|
||||
logoUrl: z.string().optional(),
|
||||
isDefault: z.boolean().default(false),
|
||||
resendApiKey: z.string().optional(),
|
||||
resendDomain: z.string().optional(),
|
||||
emailFromName: z.string().optional(),
|
||||
});
|
||||
|
||||
const InvoiceItemBackupSchema = z.object({
|
||||
date: z.string().transform((str) => new Date(str)),
|
||||
date: z.coerce.date(),
|
||||
description: z.string(),
|
||||
hours: z.number(),
|
||||
rate: z.number(),
|
||||
@@ -95,29 +109,107 @@ const InvoiceItemBackupSchema = z.object({
|
||||
|
||||
const InvoiceBackupSchema = z.object({
|
||||
invoiceNumber: z.string(),
|
||||
invoicePrefix: z.string().default("#"),
|
||||
businessName: z.string().optional(),
|
||||
businessNickname: z.string().optional(),
|
||||
clientName: z.string(),
|
||||
issueDate: z.string().transform((str) => new Date(str)),
|
||||
dueDate: z.string().transform((str) => new Date(str)),
|
||||
issueDate: z.coerce.date(),
|
||||
dueDate: z.coerce.date(),
|
||||
status: z.string().default("draft"),
|
||||
totalAmount: z.number().default(0),
|
||||
taxRate: z.number().default(0),
|
||||
currency: z.string().default("USD"),
|
||||
notes: z.string().optional(),
|
||||
emailMessage: z.string().optional(),
|
||||
items: z.array(InvoiceItemBackupSchema),
|
||||
});
|
||||
|
||||
const InvoicePaymentBackupSchema = z.object({
|
||||
invoiceNumber: z.string(),
|
||||
amount: z.number(),
|
||||
currency: z.string().default("USD"),
|
||||
date: z.coerce.date(),
|
||||
method: z.string().default("other"),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const InvoiceTemplateBackupSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.string().default("notes"),
|
||||
content: z.string(),
|
||||
isDefault: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const RecurringInvoiceItemBackupSchema = z.object({
|
||||
description: z.string(),
|
||||
hours: z.number(),
|
||||
rate: z.number(),
|
||||
position: z.number().default(0),
|
||||
});
|
||||
|
||||
const RecurringInvoiceBackupSchema = z.object({
|
||||
name: z.string(),
|
||||
clientName: z.string(),
|
||||
businessName: z.string().optional(),
|
||||
businessNickname: z.string().optional(),
|
||||
schedule: z.string().default("monthly"),
|
||||
status: z.string().default("active"),
|
||||
invoicePrefix: z.string().default("#"),
|
||||
taxRate: z.number().default(0),
|
||||
currency: z.string().default("USD"),
|
||||
notes: z.string().optional(),
|
||||
emailMessage: z.string().optional(),
|
||||
nextDueAt: z.coerce.date(),
|
||||
lastGeneratedAt: z.coerce.date().optional(),
|
||||
items: z.array(RecurringInvoiceItemBackupSchema),
|
||||
});
|
||||
|
||||
const ExpenseBackupSchema = z.object({
|
||||
clientName: z.string().optional(),
|
||||
businessName: z.string().optional(),
|
||||
businessNickname: z.string().optional(),
|
||||
invoiceNumber: z.string().optional(),
|
||||
date: z.coerce.date(),
|
||||
description: z.string(),
|
||||
amount: z.number(),
|
||||
currency: z.string().default("USD"),
|
||||
category: z.string().optional(),
|
||||
billable: z.boolean().default(false),
|
||||
reimbursable: z.boolean().default(false),
|
||||
taxDeductible: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const TimeEntryBackupSchema = z.object({
|
||||
description: z.string().default(""),
|
||||
clientName: z.string().optional(),
|
||||
invoiceNumber: z.string().optional(),
|
||||
startedAt: z.coerce.date(),
|
||||
endedAt: z.coerce.date().optional(),
|
||||
hours: z.number().optional(),
|
||||
rate: z.number().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const BackupDataSchema = z.object({
|
||||
exportDate: z.string(),
|
||||
version: z.string().default("1.0"),
|
||||
version: z.string().default("2.0"),
|
||||
user: z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string(),
|
||||
prefersReducedMotion: z.boolean().optional(),
|
||||
animationSpeedMultiplier: z.number().optional(),
|
||||
theme: z.string().optional(),
|
||||
onboardingCompletedAt: z.coerce.date().nullable().optional(),
|
||||
}),
|
||||
clients: z.array(ClientBackupSchema),
|
||||
businesses: z.array(BusinessBackupSchema),
|
||||
invoices: z.array(InvoiceBackupSchema),
|
||||
invoicePayments: z.array(InvoicePaymentBackupSchema).default([]),
|
||||
invoiceTemplates: z.array(InvoiceTemplateBackupSchema).default([]),
|
||||
recurringInvoices: z.array(RecurringInvoiceBackupSchema).default([]),
|
||||
expenses: z.array(ExpenseBackupSchema).default([]),
|
||||
timeEntries: z.array(TimeEntryBackupSchema).default([]),
|
||||
});
|
||||
|
||||
export const settingsRouter = createTRPCRouter({
|
||||
@@ -162,12 +254,45 @@ export const settingsRouter = createTRPCRouter({
|
||||
email: true,
|
||||
image: true,
|
||||
role: true,
|
||||
onboardingCompletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}),
|
||||
|
||||
getOnboardingStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, ctx.session.user.id),
|
||||
columns: {
|
||||
onboardingCompletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const [businessCount, clientCount] = await Promise.all([
|
||||
ctx.db.$count(
|
||||
businesses,
|
||||
eq(businesses.createdById, ctx.session.user.id),
|
||||
),
|
||||
ctx.db.$count(clients, eq(clients.createdById, ctx.session.user.id)),
|
||||
]);
|
||||
|
||||
return {
|
||||
completed: user?.onboardingCompletedAt != null,
|
||||
businessCount,
|
||||
clientCount,
|
||||
};
|
||||
}),
|
||||
|
||||
completeOnboarding: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({ onboardingCompletedAt: new Date() })
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Get animation preferences
|
||||
getAnimationPreferences: publicProcedure.query(async ({ ctx }) => {
|
||||
// Return defaults if not authenticated
|
||||
@@ -220,64 +345,54 @@ export const settingsRouter = createTRPCRouter({
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Get theme preferences
|
||||
getTheme: publicProcedure.query(async ({ ctx }) => {
|
||||
getColorMode: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, ctx.session.user.id),
|
||||
columns: { theme: true },
|
||||
});
|
||||
|
||||
return {
|
||||
colorMode: (user?.theme as ColorMode) ?? defaultColorMode,
|
||||
};
|
||||
}),
|
||||
|
||||
updateColorMode: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
colorMode: colorModeSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({ theme: input.colorMode })
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getPdfSettings: publicProcedure.query(async ({ ctx }) => {
|
||||
const settings = await ctx.db.query.platformSettings.findFirst({
|
||||
where: eq(platformSettings.id, "global"),
|
||||
});
|
||||
|
||||
return {
|
||||
colorTheme:
|
||||
(settings?.colorTheme as ColorTheme) ?? fallbackAppearance.colorTheme,
|
||||
customColor: settings?.customColor ?? undefined,
|
||||
theme: (settings?.theme as ColorMode) ?? fallbackAppearance.colorMode,
|
||||
interfaceTheme:
|
||||
(settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme,
|
||||
bodyFontPreference:
|
||||
(settings?.bodyFontPreference as FontPreference) ??
|
||||
defaultBodyFontPreference,
|
||||
headingFontPreference:
|
||||
(settings?.headingFontPreference as FontPreference) ??
|
||||
defaultHeadingFontPreference,
|
||||
radiusPreference:
|
||||
(settings?.radiusPreference as RadiusPreference) ??
|
||||
defaultRadiusPreference,
|
||||
sidebarStyle:
|
||||
(settings?.sidebarStyle as SidebarStyle) ?? defaultSidebarStyle,
|
||||
brandName: settings?.brandName ?? fallbackAppearance.brandName,
|
||||
brandTagline: settings?.brandTagline ?? fallbackAppearance.brandTagline,
|
||||
brandLogoText:
|
||||
settings?.brandLogoText ?? fallbackAppearance.brandLogoText,
|
||||
brandIcon: settings?.brandIcon ?? fallbackAppearance.brandIcon,
|
||||
pdfTemplate:
|
||||
(settings?.pdfTemplate as "classic" | "minimal") ??
|
||||
fallbackAppearance.pdfTemplate,
|
||||
defaultPdfSettings.pdfTemplate,
|
||||
pdfAccentColor:
|
||||
settings?.pdfAccentColor ?? fallbackAppearance.pdfAccentColor,
|
||||
settings?.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
|
||||
pdfFooterText:
|
||||
settings?.pdfFooterText ?? fallbackAppearance.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo ?? fallbackAppearance.pdfShowLogo,
|
||||
settings?.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
|
||||
pdfShowPageNumbers:
|
||||
settings?.pdfShowPageNumbers ?? fallbackAppearance.pdfShowPageNumbers,
|
||||
settings?.pdfShowPageNumbers ?? defaultPdfSettings.pdfShowPageNumbers,
|
||||
};
|
||||
}),
|
||||
|
||||
// Update theme preferences
|
||||
updateTheme: protectedProcedure
|
||||
updatePdfSettings: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
colorTheme: colorThemeSchema.optional(),
|
||||
customColor: hslChannelsSchema.optional(),
|
||||
theme: colorModeSchema.optional(),
|
||||
interfaceTheme: interfaceThemeSchema.optional(),
|
||||
bodyFontPreference: fontPreferenceSchema.optional(),
|
||||
headingFontPreference: fontPreferenceSchema.optional(),
|
||||
radiusPreference: radiusPreferenceSchema.optional(),
|
||||
sidebarStyle: sidebarStyleSchema.optional(),
|
||||
brandName: z.string().min(1).max(100).optional(),
|
||||
brandTagline: z.string().min(1).max(255).optional(),
|
||||
brandLogoText: z.string().min(1).max(100).optional(),
|
||||
brandIcon: z.string().min(1).max(20).optional(),
|
||||
pdfTemplate: pdfTemplateSchema.optional(),
|
||||
pdfAccentColor: z.string().min(4).max(50).optional(),
|
||||
pdfFooterText: z.string().min(1).max(120).optional(),
|
||||
@@ -291,57 +406,18 @@ export const settingsRouter = createTRPCRouter({
|
||||
.insert(platformSettings)
|
||||
.values({
|
||||
id: "global",
|
||||
brandName: input.brandName ?? fallbackAppearance.brandName,
|
||||
brandTagline: input.brandTagline ?? fallbackAppearance.brandTagline,
|
||||
brandLogoText:
|
||||
input.brandLogoText ?? fallbackAppearance.brandLogoText,
|
||||
brandIcon: input.brandIcon ?? fallbackAppearance.brandIcon,
|
||||
colorTheme: input.colorTheme ?? fallbackAppearance.colorTheme,
|
||||
customColor: input.customColor,
|
||||
theme: input.theme ?? fallbackAppearance.colorMode,
|
||||
interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme,
|
||||
bodyFontPreference:
|
||||
input.bodyFontPreference ?? defaultBodyFontPreference,
|
||||
headingFontPreference:
|
||||
input.headingFontPreference ?? defaultHeadingFontPreference,
|
||||
radiusPreference: input.radiusPreference ?? defaultRadiusPreference,
|
||||
sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle,
|
||||
pdfTemplate: input.pdfTemplate ?? fallbackAppearance.pdfTemplate,
|
||||
pdfTemplate: input.pdfTemplate ?? defaultPdfSettings.pdfTemplate,
|
||||
pdfAccentColor:
|
||||
input.pdfAccentColor ?? fallbackAppearance.pdfAccentColor,
|
||||
input.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
|
||||
pdfFooterText:
|
||||
input.pdfFooterText ?? fallbackAppearance.pdfFooterText,
|
||||
pdfShowLogo: input.pdfShowLogo ?? fallbackAppearance.pdfShowLogo,
|
||||
input.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
|
||||
pdfShowLogo: input.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
|
||||
pdfShowPageNumbers:
|
||||
input.pdfShowPageNumbers ?? fallbackAppearance.pdfShowPageNumbers,
|
||||
input.pdfShowPageNumbers ?? defaultPdfSettings.pdfShowPageNumbers,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: platformSettings.id,
|
||||
set: {
|
||||
...(input.brandName && { brandName: input.brandName }),
|
||||
...(input.brandTagline && { brandTagline: input.brandTagline }),
|
||||
...(input.brandLogoText && {
|
||||
brandLogoText: input.brandLogoText,
|
||||
}),
|
||||
...(input.brandIcon && { brandIcon: input.brandIcon }),
|
||||
...(input.colorTheme && { colorTheme: input.colorTheme }),
|
||||
...(input.customColor !== undefined && {
|
||||
customColor: input.customColor,
|
||||
}),
|
||||
...(input.theme && { theme: input.theme }),
|
||||
...(input.interfaceTheme && {
|
||||
interfaceTheme: input.interfaceTheme,
|
||||
}),
|
||||
...(input.bodyFontPreference && {
|
||||
bodyFontPreference: input.bodyFontPreference,
|
||||
}),
|
||||
...(input.headingFontPreference && {
|
||||
headingFontPreference: input.headingFontPreference,
|
||||
}),
|
||||
...(input.radiusPreference && {
|
||||
radiusPreference: input.radiusPreference,
|
||||
}),
|
||||
...(input.sidebarStyle && { sidebarStyle: input.sidebarStyle }),
|
||||
...(input.pdfTemplate && { pdfTemplate: input.pdfTemplate }),
|
||||
...(input.pdfAccentColor && {
|
||||
pdfAccentColor: input.pdfAccentColor,
|
||||
@@ -469,20 +545,21 @@ export const settingsRouter = createTRPCRouter({
|
||||
exportData: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get user info
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: {
|
||||
name: true,
|
||||
email: true,
|
||||
prefersReducedMotion: true,
|
||||
animationSpeedMultiplier: true,
|
||||
theme: true,
|
||||
onboardingCompletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Get all clients
|
||||
const userClients = await ctx.db.query.clients.findMany({
|
||||
where: eq(clients.createdById, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
@@ -492,14 +569,14 @@ export const settingsRouter = createTRPCRouter({
|
||||
state: true,
|
||||
postalCode: true,
|
||||
country: true,
|
||||
defaultHourlyRate: true,
|
||||
currency: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Get all businesses
|
||||
const userBusinesses = await ctx.db.query.businesses.findMany({
|
||||
where: eq(businesses.createdById, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
nickname: true,
|
||||
email: true,
|
||||
@@ -514,24 +591,27 @@ export const settingsRouter = createTRPCRouter({
|
||||
taxId: true,
|
||||
logoUrl: true,
|
||||
isDefault: true,
|
||||
resendApiKey: true,
|
||||
resendDomain: true,
|
||||
emailFromName: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userInvoiceTemplates = await ctx.db.query.invoiceTemplates.findMany({
|
||||
where: eq(invoiceTemplates.createdById, userId),
|
||||
columns: {
|
||||
name: true,
|
||||
type: true,
|
||||
content: true,
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Get all invoices with their items
|
||||
const userInvoices = await ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.createdById, userId),
|
||||
with: {
|
||||
client: {
|
||||
columns: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
business: {
|
||||
columns: {
|
||||
name: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
client: { columns: { name: true } },
|
||||
business: { columns: { name: true, nickname: true } },
|
||||
items: {
|
||||
columns: {
|
||||
date: true,
|
||||
@@ -555,13 +635,61 @@ export const settingsRouter = createTRPCRouter({
|
||||
],
|
||||
});
|
||||
|
||||
// Format the data for export
|
||||
const backupData = {
|
||||
const userPayments = await ctx.db.query.invoicePayments.findMany({
|
||||
where: eq(invoicePayments.createdById, userId),
|
||||
with: {
|
||||
invoice: { columns: { invoiceNumber: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const userRecurringInvoices = await ctx.db.query.recurringInvoices.findMany({
|
||||
where: eq(recurringInvoices.createdById, userId),
|
||||
with: {
|
||||
client: { columns: { name: true } },
|
||||
business: { columns: { name: true, nickname: true } },
|
||||
items: {
|
||||
columns: {
|
||||
description: true,
|
||||
hours: true,
|
||||
rate: true,
|
||||
position: true,
|
||||
},
|
||||
orderBy: (items, { asc }) => [
|
||||
asc(items.position),
|
||||
asc(items.createdAt),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userExpenses = await ctx.db.query.expenses.findMany({
|
||||
where: eq(expenses.createdById, userId),
|
||||
with: {
|
||||
client: { columns: { name: true } },
|
||||
business: { columns: { name: true, nickname: true } },
|
||||
invoice: { columns: { invoiceNumber: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const userTimeEntries = await ctx.db.query.timeEntries.findMany({
|
||||
where: eq(timeEntries.createdById, userId),
|
||||
with: {
|
||||
client: { columns: { name: true } },
|
||||
invoice: { columns: { invoiceNumber: true } },
|
||||
},
|
||||
orderBy: (entries, { asc }) => [asc(entries.startedAt)],
|
||||
});
|
||||
|
||||
return {
|
||||
exportDate: new Date().toISOString(),
|
||||
version: "1.0",
|
||||
version: "2.0",
|
||||
user: {
|
||||
name: user?.name ?? "",
|
||||
email: user?.email ?? "",
|
||||
prefersReducedMotion: user?.prefersReducedMotion ?? false,
|
||||
animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1,
|
||||
theme: user?.theme ?? "system",
|
||||
onboardingCompletedAt: user?.onboardingCompletedAt ?? null,
|
||||
},
|
||||
clients: userClients.map((client) => ({
|
||||
name: client.name,
|
||||
@@ -573,6 +701,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
state: client.state ?? undefined,
|
||||
postalCode: client.postalCode ?? undefined,
|
||||
country: client.country ?? undefined,
|
||||
defaultHourlyRate: client.defaultHourlyRate ?? undefined,
|
||||
currency: client.currency,
|
||||
})),
|
||||
businesses: userBusinesses.map((business) => ({
|
||||
name: business.name,
|
||||
@@ -589,24 +719,82 @@ export const settingsRouter = createTRPCRouter({
|
||||
taxId: business.taxId ?? undefined,
|
||||
logoUrl: business.logoUrl ?? undefined,
|
||||
isDefault: business.isDefault ?? false,
|
||||
resendApiKey: business.resendApiKey ?? undefined,
|
||||
resendDomain: business.resendDomain ?? undefined,
|
||||
emailFromName: business.emailFromName ?? undefined,
|
||||
})),
|
||||
invoiceTemplates: userInvoiceTemplates.map((template) => ({
|
||||
name: template.name,
|
||||
type: template.type,
|
||||
content: template.content,
|
||||
isDefault: template.isDefault,
|
||||
})),
|
||||
invoices: userInvoices.map((invoice) => ({
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoicePrefix: invoice.invoicePrefix ?? "#",
|
||||
businessName: invoice.business?.name,
|
||||
businessNickname: invoice.business?.nickname,
|
||||
businessNickname: invoice.business?.nickname ?? undefined,
|
||||
clientName: invoice.client.name,
|
||||
issueDate: invoice.issueDate,
|
||||
dueDate: invoice.dueDate,
|
||||
status: invoice.status,
|
||||
totalAmount: invoice.totalAmount,
|
||||
taxRate: invoice.taxRate,
|
||||
currency: invoice.currency,
|
||||
notes: invoice.notes ?? undefined,
|
||||
emailMessage: invoice.emailMessage ?? undefined,
|
||||
items: invoice.items,
|
||||
})),
|
||||
invoicePayments: userPayments.map((payment) => ({
|
||||
invoiceNumber: payment.invoice.invoiceNumber,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
date: payment.date,
|
||||
method: payment.method,
|
||||
notes: payment.notes ?? undefined,
|
||||
})),
|
||||
recurringInvoices: userRecurringInvoices.map((recurring) => ({
|
||||
name: recurring.name,
|
||||
clientName: recurring.client.name,
|
||||
businessName: recurring.business?.name,
|
||||
businessNickname: recurring.business?.nickname ?? undefined,
|
||||
schedule: recurring.schedule,
|
||||
status: recurring.status,
|
||||
invoicePrefix: recurring.invoicePrefix ?? "#",
|
||||
taxRate: recurring.taxRate,
|
||||
currency: recurring.currency,
|
||||
notes: recurring.notes ?? undefined,
|
||||
emailMessage: recurring.emailMessage ?? undefined,
|
||||
nextDueAt: recurring.nextDueAt,
|
||||
lastGeneratedAt: recurring.lastGeneratedAt ?? undefined,
|
||||
items: recurring.items,
|
||||
})),
|
||||
expenses: userExpenses.map((expense) => ({
|
||||
clientName: expense.client?.name,
|
||||
businessName: expense.business?.name,
|
||||
businessNickname: expense.business?.nickname ?? undefined,
|
||||
invoiceNumber: expense.invoice?.invoiceNumber,
|
||||
date: expense.date,
|
||||
description: expense.description,
|
||||
amount: expense.amount,
|
||||
currency: expense.currency,
|
||||
category: expense.category ?? undefined,
|
||||
billable: expense.billable,
|
||||
reimbursable: expense.reimbursable,
|
||||
taxDeductible: expense.taxDeductible,
|
||||
notes: expense.notes ?? undefined,
|
||||
})),
|
||||
timeEntries: userTimeEntries.map((entry) => ({
|
||||
description: entry.description,
|
||||
clientName: entry.client?.name,
|
||||
invoiceNumber: entry.invoice?.invoiceNumber,
|
||||
startedAt: entry.startedAt,
|
||||
endedAt: entry.endedAt ?? undefined,
|
||||
hours: entry.hours ?? undefined,
|
||||
rate: entry.rate ?? undefined,
|
||||
notes: entry.notes ?? undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
return backupData;
|
||||
}),
|
||||
|
||||
// Import user data (restore)
|
||||
@@ -615,12 +803,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
try {
|
||||
return await ctx.db.transaction(async (tx) => {
|
||||
// Create a map to track old to new client IDs
|
||||
const clientIdMap = new Map<string, string>();
|
||||
const businessIdMap = new Map<string, string>();
|
||||
const invoiceIdMap = new Map<string, string>();
|
||||
|
||||
// Import clients
|
||||
for (const clientData of input.clients) {
|
||||
const [newClient] = await tx
|
||||
.insert(clients)
|
||||
@@ -635,7 +823,6 @@ export const settingsRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
// Import businesses
|
||||
for (const businessData of input.businesses) {
|
||||
const [newBusiness] = await tx
|
||||
.insert(businesses)
|
||||
@@ -653,26 +840,28 @@ export const settingsRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
// Import invoices
|
||||
for (const templateData of input.invoiceTemplates) {
|
||||
await tx.insert(invoiceTemplates).values({
|
||||
...templateData,
|
||||
createdById: userId,
|
||||
});
|
||||
}
|
||||
|
||||
for (const invoiceData of input.invoices) {
|
||||
const clientId = clientIdMap.get(invoiceData.clientName);
|
||||
if (!clientId) {
|
||||
throw new Error(`Client ${invoiceData.clientName} not found`);
|
||||
backupImportError(
|
||||
`Client "${invoiceData.clientName}" not found for invoice ${invoiceData.invoiceNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
const businessId = invoiceData.businessNickname
|
||||
? (businessIdMap.get(invoiceData.businessNickname) ??
|
||||
(invoiceData.businessName
|
||||
? (businessIdMap.get(invoiceData.businessName) ?? null)
|
||||
: null))
|
||||
: invoiceData.businessName
|
||||
? (businessIdMap.get(invoiceData.businessName) ?? null)
|
||||
: null;
|
||||
const businessId = resolveBusinessId(invoiceData, businessIdMap);
|
||||
|
||||
const [newInvoice] = await tx
|
||||
.insert(invoices)
|
||||
.values({
|
||||
invoiceNumber: invoiceData.invoiceNumber,
|
||||
invoicePrefix: invoiceData.invoicePrefix,
|
||||
businessId,
|
||||
clientId,
|
||||
issueDate: invoiceData.issueDate,
|
||||
@@ -680,14 +869,17 @@ export const settingsRouter = createTRPCRouter({
|
||||
status: invoiceData.status,
|
||||
totalAmount: invoiceData.totalAmount,
|
||||
taxRate: invoiceData.taxRate,
|
||||
currency: invoiceData.currency,
|
||||
notes: invoiceData.notes,
|
||||
emailMessage: invoiceData.emailMessage,
|
||||
createdById: userId,
|
||||
})
|
||||
.returning({ id: invoices.id });
|
||||
|
||||
if (newInvoice && invoiceData.items.length > 0) {
|
||||
// Import invoice items
|
||||
if (newInvoice) {
|
||||
invoiceIdMap.set(invoiceData.invoiceNumber, newInvoice.id);
|
||||
|
||||
if (invoiceData.items.length > 0) {
|
||||
await tx.insert(invoiceItems).values(
|
||||
invoiceData.items.map((item) => ({
|
||||
...item,
|
||||
@@ -696,20 +888,197 @@ export const settingsRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const paymentData of input.invoicePayments) {
|
||||
const invoiceId = invoiceIdMap.get(paymentData.invoiceNumber);
|
||||
if (!invoiceId) {
|
||||
backupImportError(
|
||||
`Invoice "${paymentData.invoiceNumber}" not found for payment`,
|
||||
);
|
||||
}
|
||||
|
||||
await tx.insert(invoicePayments).values({
|
||||
invoiceId,
|
||||
amount: paymentData.amount,
|
||||
currency: paymentData.currency,
|
||||
date: paymentData.date,
|
||||
method: paymentData.method,
|
||||
notes: paymentData.notes,
|
||||
createdById: userId,
|
||||
});
|
||||
}
|
||||
|
||||
for (const recurringData of input.recurringInvoices) {
|
||||
const clientId = clientIdMap.get(recurringData.clientName);
|
||||
if (!clientId) {
|
||||
backupImportError(
|
||||
`Client "${recurringData.clientName}" not found for recurring invoice "${recurringData.name}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const businessId = resolveBusinessId(recurringData, businessIdMap);
|
||||
|
||||
const [newRecurring] = await tx
|
||||
.insert(recurringInvoices)
|
||||
.values({
|
||||
name: recurringData.name,
|
||||
clientId,
|
||||
businessId,
|
||||
schedule: recurringData.schedule,
|
||||
status: recurringData.status,
|
||||
invoicePrefix: recurringData.invoicePrefix,
|
||||
taxRate: recurringData.taxRate,
|
||||
currency: recurringData.currency,
|
||||
notes: recurringData.notes,
|
||||
emailMessage: recurringData.emailMessage,
|
||||
nextDueAt: recurringData.nextDueAt,
|
||||
lastGeneratedAt: recurringData.lastGeneratedAt,
|
||||
createdById: userId,
|
||||
})
|
||||
.returning({ id: recurringInvoices.id });
|
||||
|
||||
if (newRecurring && recurringData.items.length > 0) {
|
||||
await tx.insert(recurringInvoiceItems).values(
|
||||
recurringData.items.map((item) => ({
|
||||
...item,
|
||||
recurringInvoiceId: newRecurring.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const expenseData of input.expenses) {
|
||||
const clientId = expenseData.clientName
|
||||
? (clientIdMap.get(expenseData.clientName) ?? null)
|
||||
: null;
|
||||
if (expenseData.clientName && !clientId) {
|
||||
backupImportError(
|
||||
`Client "${expenseData.clientName}" not found for expense "${expenseData.description}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const businessId = resolveBusinessId(expenseData, businessIdMap);
|
||||
const invoiceId = expenseData.invoiceNumber
|
||||
? (invoiceIdMap.get(expenseData.invoiceNumber) ?? null)
|
||||
: null;
|
||||
if (expenseData.invoiceNumber && !invoiceId) {
|
||||
backupImportError(
|
||||
`Invoice "${expenseData.invoiceNumber}" not found for expense "${expenseData.description}"`,
|
||||
);
|
||||
}
|
||||
|
||||
await tx.insert(expenses).values({
|
||||
clientId,
|
||||
businessId,
|
||||
invoiceId,
|
||||
date: expenseData.date,
|
||||
description: expenseData.description,
|
||||
amount: expenseData.amount,
|
||||
currency: expenseData.currency,
|
||||
category: expenseData.category,
|
||||
billable: expenseData.billable,
|
||||
reimbursable: expenseData.reimbursable,
|
||||
taxDeductible: expenseData.taxDeductible,
|
||||
notes: expenseData.notes,
|
||||
createdById: userId,
|
||||
});
|
||||
}
|
||||
|
||||
const existingRunningEntry = await tx.query.timeEntries.findFirst({
|
||||
where: and(
|
||||
eq(timeEntries.createdById, userId),
|
||||
isNull(timeEntries.endedAt),
|
||||
),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
for (const entryData of input.timeEntries) {
|
||||
const clientId = entryData.clientName
|
||||
? (clientIdMap.get(entryData.clientName) ?? null)
|
||||
: null;
|
||||
if (entryData.clientName && !clientId) {
|
||||
backupImportError(
|
||||
`Client "${entryData.clientName}" not found for time entry`,
|
||||
);
|
||||
}
|
||||
|
||||
const invoiceId = entryData.invoiceNumber
|
||||
? (invoiceIdMap.get(entryData.invoiceNumber) ?? null)
|
||||
: null;
|
||||
if (entryData.invoiceNumber && !invoiceId) {
|
||||
backupImportError(
|
||||
`Invoice "${entryData.invoiceNumber}" not found for time entry`,
|
||||
);
|
||||
}
|
||||
|
||||
const isRunningImport = entryData.endedAt == null;
|
||||
const endedAt =
|
||||
isRunningImport && existingRunningEntry
|
||||
? entryData.startedAt
|
||||
: entryData.endedAt;
|
||||
|
||||
await tx.insert(timeEntries).values({
|
||||
description: entryData.description,
|
||||
clientId,
|
||||
invoiceId,
|
||||
startedAt: entryData.startedAt,
|
||||
endedAt,
|
||||
hours: entryData.hours,
|
||||
rate: entryData.rate,
|
||||
notes: entryData.notes,
|
||||
createdById: userId,
|
||||
});
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(users)
|
||||
.set({
|
||||
...(input.user.prefersReducedMotion !== undefined && {
|
||||
prefersReducedMotion: input.user.prefersReducedMotion,
|
||||
}),
|
||||
...(input.user.animationSpeedMultiplier !== undefined && {
|
||||
animationSpeedMultiplier: input.user.animationSpeedMultiplier,
|
||||
}),
|
||||
...(input.user.theme !== undefined && { theme: input.user.theme }),
|
||||
...(input.user.onboardingCompletedAt !== undefined && {
|
||||
onboardingCompletedAt: input.user.onboardingCompletedAt,
|
||||
}),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
imported: {
|
||||
clients: input.clients.length,
|
||||
businesses: input.businesses.length,
|
||||
invoiceTemplates: input.invoiceTemplates.length,
|
||||
invoices: input.invoices.length,
|
||||
items: input.invoices.reduce(
|
||||
invoiceItems: input.invoices.reduce(
|
||||
(sum, inv) => sum + inv.items.length,
|
||||
0,
|
||||
),
|
||||
invoicePayments: input.invoicePayments.length,
|
||||
recurringInvoices: input.recurringInvoices.length,
|
||||
recurringInvoiceItems: input.recurringInvoices.reduce(
|
||||
(sum, inv) => sum + inv.items.length,
|
||||
0,
|
||||
),
|
||||
expenses: input.expenses.length,
|
||||
timeEntries: input.timeEntries.length,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Import failed unexpectedly",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
// Get data statistics
|
||||
|
||||
@@ -133,17 +133,12 @@ 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));
|
||||
}
|
||||
|
||||
const result = await next();
|
||||
|
||||
const end = Date.now();
|
||||
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
+1
-25
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user