feat: remove start.sh script and add appearance preferences management

- Deleted the start.sh script for container management.
- Added AGENTS.md for project guidelines and development principles.
- Introduced new SQL migration files for user appearance preferences and platform settings.
- Implemented appearance provider to manage user interface themes and preferences.
- Created branding utility to define and manage branding-related constants and types.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-27 22:12:16 -04:00
parent b582b6c88e
commit fbeca7cfee
39 changed files with 3388 additions and 977 deletions
+14
View File
@@ -55,6 +55,20 @@ export async function POST(request: NextRequest) {
})
.where(eq(users.id, user.id));
if (!env.RESEND_API_KEY) {
console.warn(
"Password reset requested, but RESEND_API_KEY is not configured.",
);
return NextResponse.json(
{
success: true,
message:
"If an account with that email exists, password reset instructions have been sent.",
},
{ status: 200 },
);
}
// Send password reset email using Resend
try {
const resend = new Resend(env.RESEND_API_KEY);
+47 -34
View File
@@ -10,6 +10,7 @@ import { Label } from "~/components/ui/label";
import { toast } from "sonner";
import { Logo } from "~/components/branding/logo";
import { LegalModal } from "~/components/ui/legal-modal";
import { env } from "~/env";
import {
Mail,
Lock,
@@ -21,6 +22,7 @@ import {
} from "lucide-react";
function SignInForm() {
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
@@ -63,24 +65,27 @@ function SignInForm() {
}
return (
<div className="flex min-h-screen items-center justify-center relative overflow-hidden">
<div className="relative flex min-h-screen items-center justify-center overflow-hidden">
{/* Blob Background */}
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
<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="w-[800px] h-[800px] bg-neutral-400/30 dark:bg-neutral-500/20 rounded-full blur-3xl animate-blob"></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 h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:border md:shadow-2xl md:bg-background/80 md:backdrop-blur-xl md:border-border/50 md:rounded-3xl">
<Card className="md:bg-background/80 md:border-border/50 mx-auto h-screen w-full overflow-hidden border-0 shadow-none md:h-auto md:max-w-6xl md:rounded-3xl md:border md:shadow-2xl md:backdrop-blur-xl">
<CardContent className="grid h-full p-0 md:grid-cols-2">
{/* Hero Section - Hidden on mobile */}
<div className="bg-primary/5 relative hidden md:flex md:flex-col md:justify-center md:p-12 border-r border-border/50">
<div className="bg-primary/5 border-border/50 relative hidden border-r md:flex md:flex-col md:justify-center md:p-12">
<div className="space-y-8">
<div className="space-y-4">
<Logo size="xl" />
<div className="space-y-3">
<h1 className="text-3xl font-bold lg:text-4xl font-heading">
<h1 className="font-heading text-3xl font-bold lg:text-4xl">
Welcome back to your
<span className="text-primary italic"> invoicing workspace</span>
<span className="text-primary italic">
{" "}
invoicing workspace
</span>
</h1>
<p className="text-muted-foreground text-lg">
Continue managing your clients and creating professional
@@ -95,7 +100,9 @@ function SignInForm() {
<Users className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-foreground">Client Management</h3>
<h3 className="text-foreground font-semibold">
Client Management
</h3>
<p className="text-muted-foreground text-sm">
Organize and track all your clients in one place
</p>
@@ -107,7 +114,9 @@ function SignInForm() {
<FileText className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-foreground">Professional Invoices</h3>
<h3 className="text-foreground font-semibold">
Professional Invoices
</h3>
<p className="text-muted-foreground text-sm">
Beautiful templates that get you paid faster
</p>
@@ -119,7 +128,9 @@ function SignInForm() {
<TrendingUp className="text-primary h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-foreground">Payment Tracking</h3>
<h3 className="text-foreground font-semibold">
Payment Tracking
</h3>
<p className="text-muted-foreground text-sm">
Monitor your income with real-time insights
</p>
@@ -138,35 +149,37 @@ function SignInForm() {
</div>
<div className="space-y-2 text-center md:text-left">
<h1 className="text-3xl font-bold font-heading">Sign In</h1>
<h1 className="font-heading text-3xl font-bold">Sign In</h1>
<p className="text-muted-foreground">
Enter your credentials to access your account
</p>
</div>
<div className="space-y-4">
<Button
variant="outline"
type="button"
className="w-full h-11 relative rounded-xl"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
{authentikEnabled && (
<div className="space-y-4">
<Button
variant="outline"
type="button"
className="relative h-11 w-full rounded-xl"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/50" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="border-border/50 w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
</div>
</div>
)}
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
@@ -180,7 +193,7 @@ function SignInForm() {
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
className="h-11 pl-10 bg-background/50 border-border/60 focus:bg-background transition-all"
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="m@example.com"
/>
</div>
@@ -204,7 +217,7 @@ function SignInForm() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="h-11 pl-10 bg-background/50 border-border/60 focus:bg-background transition-all"
className="bg-background/50 border-border/60 focus:bg-background h-11 pl-10 transition-all"
placeholder="Enter your password"
/>
</div>
@@ -212,7 +225,7 @@ function SignInForm() {
<Button
type="submit"
className="h-11 w-full rounded-xl text-base shadow-lg shadow-primary/20 hover:shadow-primary/30"
className="shadow-primary/20 hover:shadow-primary/30 h-11 w-full rounded-xl text-base shadow-lg"
disabled={loading}
>
{loading ? (
@@ -30,15 +30,15 @@ export function InvoiceDetailsSkeleton() {
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
<div className="space-y-1 sm:space-y-0 text-sm">
<div className="space-y-1 text-sm sm:space-y-0">
<div className="flex gap-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-32 hidden sm:block" />
<Skeleton className="hidden h-4 w-32 sm:block" />
</div>
</div>
</div>
<div className="flex-shrink-0 text-left sm:text-right">
<Skeleton className="h-4 w-24 mb-1 sm:ml-auto" />
<Skeleton className="mb-1 h-4 w-24 sm:ml-auto" />
<Skeleton className="h-9 w-32 sm:ml-auto" />
</div>
</div>
@@ -118,7 +118,7 @@ export function InvoiceDetailsSkeleton() {
<div className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<Skeleton className="h-5 w-3/4 mb-2" />
<Skeleton className="mb-2 h-5 w-3/4" />
<div className="flex gap-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
@@ -156,7 +156,7 @@ export function InvoiceDetailsSkeleton() {
{/* Right Column - Actions */}
<div className="space-y-6">
<Card className="sticky top-20">
<Card className="lg:sticky lg:top-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded-full" />
@@ -25,6 +25,9 @@ export function PDFDownloadButton({
{ id: invoiceId },
{ enabled: false },
);
const { data: platformTheme } = api.settings.getTheme.useQuery(undefined, {
staleTime: 60_000,
});
const handleDownloadPDF = async () => {
if (isGenerating) return;
@@ -55,7 +58,13 @@ export function PDFDownloadButton({
items: invoiceData.items,
};
await generateInvoicePDF(pdfData);
await generateInvoicePDF(pdfData, {
pdfTemplate: platformTheme?.pdfTemplate,
pdfAccentColor: platformTheme?.pdfAccentColor,
pdfFooterText: platformTheme?.pdfFooterText,
pdfShowLogo: platformTheme?.pdfShowLogo,
pdfShowPageNumbers: platformTheme?.pdfShowPageNumbers,
});
toast.success("PDF downloaded successfully");
} catch (error) {
console.error("PDF generation error:", error);
+1 -1
View File
@@ -411,7 +411,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
{/* Right Column - Actions */}
<div className="space-y-6">
<Card className="sticky top-20">
<Card className="lg:sticky lg:top-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Check className="h-5 w-5" />
+8 -3
View File
@@ -25,6 +25,11 @@ import {
} from "recharts";
import { TrendingUp, DollarSign, Clock, Users, Download, Receipt, FileText } from "lucide-react";
function toNumericChartValue(value: unknown) {
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
return Number.isFinite(numericValue) ? numericValue : 0;
}
export default function ReportsPage() {
const { data: invoices = [], isLoading: invoicesLoading } = api.invoices.getAll.useQuery();
const { data: expenses = [], isLoading: expensesLoading } = api.expenses.getAll.useQuery();
@@ -259,7 +264,7 @@ export default function ReportsPage() {
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
<Tooltip formatter={(value) => [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
@@ -281,7 +286,7 @@ export default function ReportsPage() {
<BarChart data={overviewData.topClients} layout="vertical">
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
<Tooltip formatter={(value) => [formatCurrency(toNumericChartValue(value)), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
@@ -433,7 +438,7 @@ export default function ReportsPage() {
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip
formatter={(v: number, name: string) => [formatCurrency(v), name === "income" ? "Income" : "Expenses"]}
formatter={(value, name) => [formatCurrency(toNumericChartValue(value)), name === "income" ? "Income" : "Expenses"]}
contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }}
/>
<Bar dataKey="income" name="income" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]} />
File diff suppressed because it is too large Load Diff
+63 -13
View File
@@ -6,26 +6,34 @@ import { Inter, Playfair_Display, Geist_Mono } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react";
import { Toaster } from "~/components/ui/sonner";
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
import { AppearanceProvider } from "~/components/providers/appearance-provider";
import {
brand,
defaultBodyFontPreference,
defaultFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
} from "~/lib/branding";
import { UmamiScript } from "~/components/analytics/umami-script";
export const metadata: Metadata = {
title: "beenvoice - Invoicing Made Simple",
description:
"Simple and efficient invoicing for freelancers and small businesses",
title: `${brand.name} - Invoicing Made Simple`,
description: brand.tagline,
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
variable: "--font-inter",
display: "swap",
});
const playfair = Playfair_Display({
subsets: ["latin"],
variable: "--font-heading",
variable: "--font-playfair",
display: "swap",
});
@@ -42,20 +50,62 @@ export default function RootLayout({
<html
suppressHydrationWarning
lang="en"
data-interface-theme={defaultInterfaceTheme}
data-font={defaultFontPreference}
data-body-font={defaultBodyFontPreference}
data-heading-font={defaultHeadingFontPreference}
data-radius={defaultRadiusPreference}
data-sidebar-style={defaultSidebarStyle}
data-color-mode="system"
data-color-theme="slate"
className={`${inter.variable} ${playfair.variable} ${geistMono.variable}`}
>
<head>
<script
id="appearance-init"
dangerouslySetInnerHTML={{
__html: `
try {
var defaults = {
interfaceTheme: "${defaultInterfaceTheme}",
fontPreference: "${defaultFontPreference}",
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 root = document.documentElement;
root.dataset.interfaceTheme = appearance.interfaceTheme;
root.dataset.font = appearance.fontPreference;
root.dataset.bodyFont = appearance.bodyFontPreference || appearance.fontPreference;
root.dataset.headingFont = appearance.headingFontPreference || appearance.fontPreference;
root.dataset.radius = appearance.radiusPreference;
root.dataset.sidebarStyle = appearance.sidebarStyle;
root.dataset.colorMode = appearance.colorMode;
root.dataset.colorTheme = appearance.colorTheme;
if (appearance.colorMode === "dark") root.classList.add("dark");
if (appearance.customColor) root.style.setProperty("--custom-primary", appearance.customColor);
} catch {}
`,
}}
/>
</head>
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
<div className="brand-background pointer-events-none fixed inset-0 -z-10 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="w-[800px] h-[800px] bg-neutral-400/40 dark:bg-neutral-500/30 rounded-full blur-3xl animate-blob"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/40 blur-3xl dark:bg-neutral-500/30"></div>
</div>
<TRPCReactProvider>
<AnimationPreferencesProvider>
<div className="relative z-10">
{children}
</div>
</AnimationPreferencesProvider>
<AppearanceProvider>
<AnimationPreferencesProvider>
<div className="relative z-10">{children}</div>
</AnimationPreferencesProvider>
</AppearanceProvider>
<Toaster />
<UmamiScript />
</TRPCReactProvider>
+85 -49
View File
@@ -12,20 +12,21 @@ import {
BarChart3,
Rocket,
} from "lucide-react";
import { brand } from "~/lib/branding";
export default function HomePage() {
return (
<div className="min-h-screen relative overflow-x-hidden">
<div className="relative min-h-screen overflow-x-hidden">
<AuthRedirect />
{/* Blob Background for Homepage */}
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
<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="w-[800px] h-[800px] bg-neutral-400/30 dark:bg-neutral-500/20 rounded-full blur-3xl animate-blob"></div>
<div className="animate-blob h-[800px] w-[800px] rounded-full bg-neutral-400/30 blur-3xl dark:bg-neutral-500/20"></div>
</div>
{/* Navigation */}
<nav className="fixed top-4 left-4 right-4 z-50 m-4 rounded-2xl border border-border/60 bg-background/80 backdrop-blur-md">
<nav className="border-border/60 bg-background/80 fixed top-4 right-4 left-4 z-50 m-4 rounded-2xl border backdrop-blur-md">
<div className="mx-auto px-6">
<div className="flex h-16 items-center justify-between">
<Logo />
@@ -67,25 +68,25 @@ export default function HomePage() {
<section className="relative pt-48 pb-32">
<div className="container mx-auto px-4 text-center">
<div className="mx-auto max-w-4xl">
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 border px-4 py-1 text-sm rounded-full">
<Badge className="bg-primary/10 text-primary border-primary/20 mb-8 rounded-full border px-4 py-1 text-sm">
<Zap className="mr-2 h-3.5 w-3.5" />
Completely Free for Everyone
</Badge>
<h1 className="text-foreground mb-8 text-6xl font-heading font-bold tracking-tight sm:text-7xl lg:text-8xl leading-tight">
Invoicing Made <br />
<h1 className="text-foreground font-heading mb-8 text-6xl leading-tight font-bold tracking-tight sm:text-7xl lg:text-8xl">
{brand.name} <br />
<span className="text-primary italic">Beautifully Simple.</span>
</h1>
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl text-xl leading-relaxed font-sans">
Create professional invoices, manage clients, and track payments with a tool that feels as good as it looks.
<p className="text-muted-foreground mx-auto mb-12 max-w-2xl font-sans text-xl leading-relaxed">
{brand.tagline}
</p>
<div className="flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
<Link href="/auth/register">
<Button
size="lg"
className="h-14 px-10 text-lg rounded-2xl shadow-xl shadow-primary/20 hover:shadow-2xl hover:shadow-primary/30 transition-all duration-300"
className="shadow-primary/20 hover:shadow-primary/30 h-14 rounded-2xl px-10 text-lg shadow-xl transition-all duration-300 hover:shadow-2xl"
>
Start For Free
<ArrowRight className="ml-2 h-5 w-5" />
@@ -95,14 +96,14 @@ export default function HomePage() {
<Button
variant="outline"
size="lg"
className="h-14 px-10 text-lg rounded-2xl border-border/50 bg-background/50 hover:bg-background/80 backdrop-blur-sm"
className="border-border/50 bg-background/50 hover:bg-background/80 h-14 rounded-2xl px-10 text-lg backdrop-blur-sm"
>
Learn More
</Button>
</a>
</div>
<div className="mt-16 text-muted-foreground/80 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8">
<div className="text-muted-foreground/80 mt-16 flex flex-col items-center justify-center gap-2 text-sm sm:flex-row sm:gap-8">
<div className="flex items-center gap-2">
<Check className="text-primary h-4 w-4" />
<span>No credit card required</span>
@@ -121,11 +122,12 @@ export default function HomePage() {
</section>
{/* Features Section */}
<section id="features" className="py-24 relative">
<div className="container mx-auto px-4 relative z-10">
<section id="features" className="relative py-24">
<div className="relative z-10 container mx-auto px-4">
<div className="mb-20 text-center">
<h2 className="text-foreground mb-6 text-4xl font-heading font-bold sm:text-5xl">
Everything you need to <span className="italic text-primary">thrive</span>
<h2 className="text-foreground font-heading mb-6 text-4xl font-bold sm:text-5xl">
Everything you need to{" "}
<span className="text-primary italic">thrive</span>
</h2>
<p className="text-muted-foreground mx-auto max-w-2xl text-lg">
Powerful features wrapped in a calm, focused interface.
@@ -137,28 +139,46 @@ export default function HomePage() {
{
icon: Rocket,
title: "Quick Setup",
description: "Start creating invoices immediately. No complicated setup required.",
items: ["Simple client management", "Professional templates", "Easy invoice sending"]
description:
"Start creating invoices immediately. No complicated setup required.",
items: [
"Simple client management",
"Professional templates",
"Easy invoice sending",
],
},
{
icon: BarChart3,
title: "Payment Tracking",
description: "Keep track of invoice status and monitor your payments effortlessly.",
items: ["Invoice status tracking", "Payment history", "Overdue notifications"]
description:
"Keep track of invoice status and monitor your payments effortlessly.",
items: [
"Invoice status tracking",
"Payment history",
"Overdue notifications",
],
},
{
icon: Shield,
title: "Professional Features",
description: "Tools that make you look professional and get you paid faster.",
items: ["PDF generation", "Custom tax rates", "Professional numbering"]
}
description:
"Tools that make you look professional and get you paid faster.",
items: [
"PDF generation",
"Custom tax rates",
"Professional numbering",
],
},
].map((feature, i) => (
<Card key={i} className="group hover:-translate-y-2 transition-transform duration-500 border-border/40 bg-background/60 backdrop-blur-xl">
<Card
key={i}
className="group border-border/40 bg-background/60 backdrop-blur-xl transition-transform duration-500 hover:-translate-y-2"
>
<CardContent className="p-8">
<div className="bg-primary/10 text-primary mb-6 inline-flex rounded-2xl p-4">
<feature.icon className="h-8 w-8" />
</div>
<h3 className="text-foreground mb-4 text-2xl font-bold font-heading">
<h3 className="text-foreground font-heading mb-4 text-2xl font-bold">
{feature.title}
</h3>
<p className="text-muted-foreground mb-6 leading-relaxed">
@@ -166,8 +186,11 @@ export default function HomePage() {
</p>
<ul className="space-y-3">
{feature.items.map((item, j) => (
<li key={j} className="flex items-center gap-3 text-sm text-foreground/80">
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
<li
key={j}
className="text-foreground/80 flex items-center gap-3 text-sm"
>
<div className="bg-primary h-1.5 w-1.5 rounded-full" />
{item}
</li>
))}
@@ -180,39 +203,45 @@ export default function HomePage() {
</section>
{/* Pricing Section */}
<section id="pricing" className="py-24 relative overflow-hidden">
<div className="container mx-auto px-4 relative z-10">
<div className="max-w-4xl mx-auto text-center mb-16">
<h2 className="text-5xl font-heading font-bold mb-6">Simple Pricing</h2>
<p className="text-xl text-muted-foreground">Focus on your work, not on fees.</p>
<section id="pricing" className="relative overflow-hidden py-24">
<div className="relative z-10 container mx-auto px-4">
<div className="mx-auto mb-16 max-w-4xl text-center">
<h2 className="font-heading mb-6 text-5xl font-bold">
Simple Pricing
</h2>
<p className="text-muted-foreground text-xl">
Focus on your work, not on fees.
</p>
</div>
<div className="max-w-md mx-auto">
<Card className="relative overflow-visible border-primary/50 shadow-2xl shadow-primary/5 bg-background/80 backdrop-blur-xl">
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground px-6 py-1.5 rounded-full text-sm font-medium shadow-lg">
<div className="mx-auto max-w-md">
<Card className="border-primary/50 shadow-primary/5 bg-background/80 relative overflow-visible shadow-2xl backdrop-blur-xl">
<div className="bg-primary text-primary-foreground absolute -top-4 left-1/2 -translate-x-1/2 rounded-full px-6 py-1.5 text-sm font-medium shadow-lg">
Forever Free
</div>
<CardContent className="p-10 text-center">
<div className="mb-2 text-6xl font-bold font-heading">$0</div>
<div className="text-muted-foreground mb-8">No credit card required.</div>
<div className="font-heading mb-2 text-6xl font-bold">$0</div>
<div className="text-muted-foreground mb-8">
No credit card required.
</div>
<div className="space-y-4 mb-10 text-left pl-8">
<div className="mb-10 space-y-4 pl-8 text-left">
{[
"Unlimited Invoices",
"Unlimited Clients",
"PDF Downloads",
"Payment Tracking",
"Email Support"
"Email Support",
].map((item, i) => (
<div key={i} className="flex items-center gap-3">
<Check className="h-5 w-5 text-primary shrink-0" />
<Check className="text-primary h-5 w-5 shrink-0" />
<span className="text-foreground/90">{item}</span>
</div>
))}
</div>
<Link href="/auth/register" className="block">
<Button size="lg" className="w-full text-lg h-12 rounded-xl">
<Button size="lg" className="h-12 w-full rounded-xl text-lg">
Get Started
</Button>
</Link>
@@ -223,20 +252,27 @@ export default function HomePage() {
</section>
{/* Footer */}
<footer className="border-t border-border/40 bg-background/50 backdrop-blur-sm py-12 mt-12">
<div className="container mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-6">
<footer className="border-border/40 bg-background/50 mt-12 border-t py-12 backdrop-blur-sm">
<div className="container mx-auto flex flex-col items-center justify-between gap-6 px-6 md:flex-row">
<div className="flex items-center gap-3">
<Logo size="sm" />
<span className="text-sm text-muted-foreground">© 2024 beenvoice</span>
<span className="text-muted-foreground text-sm">
© 2024 beenvoice
</span>
</div>
<div className="flex gap-8 text-sm text-muted-foreground">
<a href="#" className="hover:text-foreground transition-colors">Privacy</a>
<a href="#" className="hover:text-foreground transition-colors">Terms</a>
<a href="#" className="hover:text-foreground transition-colors">Contact</a>
<div className="text-muted-foreground flex gap-8 text-sm">
<a href="#" className="hover:text-foreground transition-colors">
Privacy
</a>
<a href="#" className="hover:text-foreground transition-colors">
Terms
</a>
<a href="#" className="hover:text-foreground transition-colors">
Contact
</a>
</div>
</div>
</footer>
</div>
);
}
+42 -9
View File
@@ -1,6 +1,8 @@
"use client";
import { motion } from "framer-motion";
import { brand } from "~/lib/branding";
import { useAppearance } from "~/components/providers/appearance-provider";
import { cn } from "~/lib/utils";
interface LogoProps {
@@ -10,6 +12,9 @@ interface LogoProps {
}
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 sizeClasses = {
sm: "text-base",
md: "text-xl",
@@ -19,7 +24,15 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
};
if (!animated) {
return <LogoContent className={className} size={size} sizeClasses={sizeClasses} />;
return (
<LogoContent
className={className}
size={size}
sizeClasses={sizeClasses}
logoText={logoText}
icon={icon}
/>
);
}
return (
@@ -27,7 +40,11 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1, ease: "easeOut" }}
className={cn("flex items-center font-mono", sizeClasses[size], className)}
className={cn(
"flex items-center font-mono",
sizeClasses[size],
className,
)}
>
<motion.span
initial={{ opacity: 0 }}
@@ -35,7 +52,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}
</motion.span>
{size !== "icon" && (
<>
@@ -51,7 +68,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.04, duration: 0.05, ease: "easeOut" }}
className="text-foreground font-bold tracking-tight"
>
been
{logoText.slice(0, Math.ceil(logoText.length / 2))}
</motion.span>
<motion.span
initial={{ opacity: 0 }}
@@ -59,7 +76,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
transition={{ delay: 0.06, duration: 0.05, ease: "easeOut" }}
className="text-foreground/70 font-bold tracking-tight"
>
voice
{logoText.slice(Math.ceil(logoText.length / 2))}
</motion.span>
</>
)}
@@ -71,19 +88,35 @@ function LogoContent({
className,
size,
sizeClasses,
logoText,
icon,
}: {
className?: string;
size: "sm" | "md" | "lg" | "xl" | "icon";
sizeClasses: Record<string, string>;
logoText: string;
icon: string;
}) {
return (
<div className={cn("flex items-center font-mono", sizeClasses[size], className)}>
<span className="text-primary font-bold tracking-tight">$</span>
<div
className={cn(
"flex items-center font-mono",
sizeClasses[size],
className,
)}
>
<span className="text-primary font-bold tracking-tight">
{icon}
</span>
{size !== "icon" && (
<>
<span className="inline-block w-1"></span>
<span className="text-foreground font-bold tracking-tight">been</span>
<span className="text-foreground/70 font-bold tracking-tight">voice</span>
<span className="text-foreground font-bold tracking-tight">
{logoText.slice(0, Math.ceil(logoText.length / 2))}
</span>
<span className="text-foreground/70 font-bold tracking-tight">
{logoText.slice(Math.ceil(logoText.length / 2))}
</span>
</>
)}
</div>
+84 -7
View File
@@ -20,6 +20,7 @@ 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";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import {
@@ -30,6 +31,7 @@ import {
List,
FileText,
ChevronDown,
Mail,
} from "lucide-react";
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
import { Textarea } from "~/components/ui/textarea";
@@ -58,7 +60,7 @@ interface InvoiceFormProps {
function InvoiceFormSkeleton() {
return (
<div className="space-y-6 pb-32">
<div className="space-y-6 pb-8">
<PageHeader
title="Loading..."
description="Loading invoice form"
@@ -199,6 +201,16 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const total = subtotal + taxAmount;
return { subtotal, taxAmount, total };
}, [formData.items, formData.taxRate]);
const selectedClient = React.useMemo(
() => clients?.find((client) => client.id === formData.clientId),
[clients, formData.clientId],
);
const selectedBusiness = React.useMemo(
() =>
businesses?.find((business) => business.id === formData.businessId) ??
businesses?.find((business) => business.isDefault),
[businesses, formData.businessId],
);
// Handlers (addItem, updateItem etc. - same as before)
const addItem = (date?: unknown) => {
@@ -370,7 +382,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
return (
<>
<div className="page-enter space-y-6 pb-32">
<div className="page-enter space-y-6 pb-8">
<PageHeader
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
description="Manage your invoice"
@@ -393,7 +405,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<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-3 rounded-xl p-1">
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1">
<TabsTrigger
value="details"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
@@ -412,6 +424,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
>
Timesheet
</TabsTrigger>
<TabsTrigger
value="preview"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
>
Preview
</TabsTrigger>
</TabsList>
{/* DETAILS TAB */}
@@ -419,7 +437,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
value="details"
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
>
<Card className="h-fit">
<Card className="h-full">
<CardHeader>
<CardTitle className="flex gap-2 text-base">
<User className="h-4 w-4" /> Client Details
@@ -495,10 +513,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardContent>
</Card>
<Card className="h-fit">
<Card className="h-full">
<CardHeader>
<CardTitle className="flex gap-2 text-base">
<Tag className="h-4 w-4" /> Invoice Config
<Tag className="h-4 w-4" /> Invoice Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@@ -524,7 +542,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[96px_1fr] sm:gap-4">
<div className="space-y-2">
<Label>Prefix</Label>
<Input
@@ -536,6 +554,17 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>Invoice Number</Label>
<Input
value={formData.invoiceNumber}
onChange={(e) =>
updateField("invoiceNumber", e.target.value)
}
placeholder="INV-20260428-000001"
className="w-full font-mono"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
@@ -717,6 +746,54 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardContent>
</Card>
</TabsContent>
<TabsContent
value="preview"
className="mt-6 focus-visible:outline-none"
>
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
<Mail className="h-5 w-5" /> Email Preview
</CardTitle>
</CardHeader>
<CardContent>
<EmailPreview
subject={`Invoice ${formData.invoiceNumber} from ${
selectedBusiness?.name ?? "Your Business"
}`}
fromEmail={selectedBusiness?.email ?? ""}
toEmail={selectedClient?.email ?? ""}
content=""
invoice={{
invoiceNumber: formData.invoiceNumber,
issueDate: formData.issueDate,
dueDate: formData.dueDate,
taxRate: formData.taxRate,
status: formData.status,
totalAmount: totals.total,
client: selectedClient
? {
name: selectedClient.name,
email: selectedClient.email,
}
: undefined,
business: selectedBusiness
? {
name: selectedBusiness.name,
email: selectedBusiness.email,
}
: undefined,
items: formData.items.map((item) => ({
id: item.id,
hours: item.hours,
rate: item.rate,
})),
}}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
+64 -80
View File
@@ -49,79 +49,59 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
<div
ref={ref}
className={cn(
"bg-card group hover:border-primary/20 hidden rounded-xl border p-4 shadow-sm transition-all md:block",
"group hover:bg-muted/40 hidden min-h-16 grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] items-center gap-2 border-b px-3 py-2 transition-colors md:grid",
)}
>
<div className="flex items-center gap-3">
{/* Main Content */}
<div className="flex-1 space-y-3">
{/* Description */}
<div>
<Input
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="w-full text-sm font-medium"
/>
</div>
<DatePicker
date={item.date}
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
size="sm"
className="w-full"
inputClassName="h-9"
/>
{/* Controls Row */}
<div className="flex flex-wrap items-center gap-3">
{/* Date */}
<DatePicker
date={item.date}
onDateChange={(date) =>
onUpdate(index, "date", date ?? new Date())
}
size="sm"
className="w-full sm:w-[180px]"
inputClassName="h-9"
/>
<Input
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="h-9 w-full text-sm font-medium"
/>
{/* Hours */}
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="auto"
className="h-9 min-w-[100px] flex-1 font-mono"
suffix="h"
/>
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
suffix="h"
/>
{/* Rate */}
<NumberInput
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
min={0}
step={1}
prefix="$"
width="auto"
className="h-9 min-w-[100px] flex-1 font-mono"
/>
<NumberInput
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
min={0}
step={1}
prefix="$"
width="full"
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14"
/>
{/* Amount */}
<div className="ml-auto">
<span className="text-primary font-semibold">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
{/* Actions */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="text-primary text-right font-mono font-semibold">
${(item.hours * item.rate).toFixed(2)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
},
@@ -240,7 +220,15 @@ export function InvoiceLineItems({
return (
<div className={cn("space-y-2", className)}>
<AnimatePresence>
<div className="space-y-2">
<div className="space-y-2 md:space-y-0 md:overflow-hidden md:rounded-lg md:border">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] gap-2 border-b px-3 py-2 text-xs font-medium md:grid">
<span>Date</span>
<span>Description</span>
<span className="text-right">Hours</span>
<span className="text-right">Rate</span>
<span className="text-right">Amount</span>
<span />
</div>
{items.map((item, index) => (
<React.Fragment key={item.id}>
{/* Desktop/Tablet Card */}
@@ -275,19 +263,15 @@ export function InvoiceLineItems({
</AnimatePresence>
{/* Add Item Button */}
<div className="px-3 pt-3">
<div className="border-t pt-6">
<Button
type="button"
variant="outline"
onClick={onAddItem}
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 w-full border-dashed py-8 transition-all"
>
<Plus className="mr-2 h-4 w-4" />
Add Line Item
</Button>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={onAddItem}
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-3 w-full border-dashed py-6 transition-all"
>
<Plus className="mr-2 h-4 w-4" />
Add Line Item
</Button>
</div>
);
}
+10 -7
View File
@@ -8,9 +8,11 @@ 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";
function DashboardContent({ children }: { children: React.ReactNode }) {
const { isCollapsed } = useSidebar();
const { sidebarStyle } = useAppearance();
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
return (
@@ -21,7 +23,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
</div>
{/* Mobile Sidebar (Sheet) */}
<div className="md:hidden fixed top-0 left-0 right-0 h-16 bg-background/80 backdrop-blur-md border-b z-50 px-4 flex items-center">
<div className="fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b bg-background/80 px-4 backdrop-blur-md md:hidden">
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="h-10 w-10 bg-background shadow-sm" suppressHydrationWarning>
@@ -47,13 +49,14 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
suppressHydrationWarning
className={cn(
"flex-1 min-h-screen min-w-0 transition-all duration-300 ease-in-out",
// Desktop margins based on collapsed state
"md:ml-0",
// Sidebar is fixed at left: 1rem (16px), width: 16rem (256px) or 4rem (64px)
// We need margin-left = left + width + gap
// Expanded: 16px + 256px + 16px (gap) = 288px (18rem)
// Collapsed: 16px + 64px + 16px (gap) = 96px (6rem)
isCollapsed ? "md:ml-24" : "md:ml-[18rem]"
sidebarStyle === "floating"
? isCollapsed
? "md:ml-24"
: "md:ml-[18rem]"
: isCollapsed
? "md:ml-16"
: "md:ml-64",
)}
>
<div className="p-4 pt-16 md:pt-4">
+13 -47
View File
@@ -1,8 +1,10 @@
"use client";
import React, { useEffect, useState } from "react";
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 */
@@ -13,74 +15,38 @@ interface FloatingActionBarProps {
className?: string;
}
import { useSidebar } from "~/components/layout/sidebar-provider";
export function FloatingActionBar({
leftContent,
children,
className,
}: FloatingActionBarProps) {
const [isDocked, setIsDocked] = useState(false);
const { isCollapsed } = useSidebar();
useEffect(() => {
const handleScroll = () => {
// Check if we're truly at the bottom of the page
const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = document.documentElement.scrollTop;
const clientHeight = document.documentElement.clientHeight;
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
// Only dock when we're within 50px of the actual bottom AND there's content to scroll
const hasScrollableContent = scrollHeight > clientHeight;
const shouldDock = hasScrollableContent && distanceFromBottom <= 50;
// If content is too small, keep it at bottom of viewport
const contentTooSmall = scrollHeight <= clientHeight + 200;
setIsDocked(shouldDock && !contentTooSmall);
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll(); // Check initial state
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const { sidebarStyle } = useAppearance();
return (
<div
className={cn(
// Base positioning - always at bottom
"fixed right-0 z-50 transition-all duration-300 ease-in-out",
// Safe area and sidebar adjustments
"pb-safe-area-inset-bottom left-0",
isCollapsed ? "md:left-24" : "md:left-[18rem]",
// Conditional centering based on dock state
isDocked ? "flex justify-center" : "",
// Dynamic bottom positioning
isDocked ? "bottom-4" : "bottom-0",
// Add entrance animation
"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",
"animate-slide-in-bottom",
className,
)}
>
{/* Content container - full width when floating, content width when docked */}
<div
className={cn(
"w-full transition-transform duration-300",
isDocked ? "mx-auto mb-0 px-4" : "mb-4 px-4",
)}
>
<div className="w-full px-4 transition-transform duration-300">
<Card className="hover-lift bg-card border-border border shadow-lg">
<CardContent className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4">
{/* Left content */}
{leftContent && (
<div className="text-card-foreground animate-fade-in flex flex-1 items-center gap-3">
{leftContent}
</div>
)}
{/* Right actions */}
<div className="animate-fade-in animate-delay-100 flex items-center gap-2 sm:gap-3">
{children}
</div>
+3 -3
View File
@@ -42,9 +42,9 @@ export function PageHeader({
return (
<div className={`animate-fade-in-down mb-6 ${className}`}>
{variant === "large-gradient" || variant === "gradient" ? (
<div className="rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden relative">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent pointer-events-none" />
<div className="p-6 relative">
<div className="platform-header-surface rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden relative">
<div className="platform-header-gradient absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent pointer-events-none" />
<div className="platform-header-content p-6 relative">
<DashboardBreadcrumbs className="mb-4" />
{/* UPDATED: flex-col on mobile to prevent squishing, row on sm+ */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
+7 -3
View File
@@ -25,6 +25,7 @@ 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";
interface SidebarProps {
mobile?: boolean;
@@ -36,6 +37,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
const { data: session, isPending } = authClient.useSession();
// const session = { user: null } as any; const isPending = false;
const { isCollapsed, toggleCollapse } = useSidebar();
const { sidebarStyle } = useAppearance();
// If mobile, always expanded
const collapsed = mobile ? false : isCollapsed;
@@ -214,9 +216,11 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
return (
<aside
className={cn(
"fixed top-4 bottom-4 left-4 z-30 hidden md:flex flex-col",
"bg-background/80 backdrop-blur-xl border-border/50 border shadow-xl rounded-3xl transition-all duration-300 ease-in-out",
isCollapsed ? "w-16" : "w-64"
"fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex",
sidebarStyle === "floating"
? "top-4 bottom-4 left-4 border-border/50 rounded-3xl border bg-background/80 shadow-xl backdrop-blur-xl"
: "top-0 bottom-0 left-0 rounded-none border-r border-border bg-background shadow-none",
isCollapsed ? "w-16" : "w-64",
)}
>
{SidebarContent}
@@ -0,0 +1,384 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
defaultFontPreference,
defaultBodyFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
brand as defaultBrand,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
import { api } from "~/trpc/react";
type AppearancePreferences = {
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: "classic" | "minimal";
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
type AppearancePatch = Partial<AppearancePreferences>;
type ServerAppearance = {
interfaceTheme: InterfaceTheme;
fontPreference: FontPreference;
bodyFontPreference: FontPreference;
headingFontPreference: FontPreference;
radiusPreference: RadiusPreference;
sidebarStyle: SidebarStyle;
theme: ColorMode;
colorTheme: ColorTheme;
customColor?: string;
brandName: string;
brandTagline: string;
brandLogoText: string;
brandIcon: string;
pdfTemplate: "classic" | "minimal";
pdfAccentColor: string;
pdfFooterText: string;
pdfShowLogo: boolean;
pdfShowPageNumbers: boolean;
};
type AppearanceContextValue = AppearancePreferences & {
updateAppearance: (patch: AppearancePatch) => void;
isUpdating: boolean;
};
const STORAGE_KEY = "bv.appearance";
const defaultAppearance: AppearancePreferences = {
interfaceTheme: defaultInterfaceTheme,
fontPreference: defaultFontPreference,
bodyFontPreference: defaultBodyFontPreference,
headingFontPreference: defaultHeadingFontPreference,
radiusPreference: defaultRadiusPreference,
sidebarStyle: defaultSidebarStyle,
colorMode: "system",
colorTheme: "slate",
brandName: defaultBrand.name,
brandTagline: defaultBrand.tagline,
brandLogoText: defaultBrand.logoText,
brandIcon: defaultBrand.icon,
pdfTemplate: "classic",
pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
};
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
function getServerAppearancePatch(
serverAppearance: ServerAppearance,
): AppearancePatch {
return {
interfaceTheme: serverAppearance.interfaceTheme,
fontPreference: serverAppearance.fontPreference,
bodyFontPreference: serverAppearance.bodyFontPreference,
headingFontPreference: serverAppearance.headingFontPreference,
radiusPreference: serverAppearance.radiusPreference,
sidebarStyle: serverAppearance.sidebarStyle,
colorMode: serverAppearance.theme,
colorTheme: serverAppearance.colorTheme,
customColor: serverAppearance.customColor,
brandName: serverAppearance.brandName,
brandTagline: serverAppearance.brandTagline,
brandLogoText: serverAppearance.brandLogoText,
brandIcon: serverAppearance.brandIcon,
pdfTemplate: serverAppearance.pdfTemplate,
pdfAccentColor: serverAppearance.pdfAccentColor,
pdfFooterText: serverAppearance.pdfFooterText,
pdfShowLogo: serverAppearance.pdfShowLogo,
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
};
}
function isInterfaceTheme(value: unknown): value is InterfaceTheme {
return (
value === "beenvoice" ||
value === "shadcn" ||
value === "minimal" ||
value === "editorial"
);
}
function isFontPreference(value: unknown): value is FontPreference {
return (
value === "brand" ||
value === "platform" ||
value === "inter" ||
value === "serif"
);
}
function isColorMode(value: unknown): value is ColorMode {
return value === "light" || value === "dark" || value === "system";
}
function isColorTheme(value: unknown): value is ColorTheme {
return (
value === "slate" ||
value === "blue" ||
value === "green" ||
value === "rose" ||
value === "orange" ||
value === "custom"
);
}
function isRadiusPreference(value: unknown): value is RadiusPreference {
return (
value === "none" ||
value === "sm" ||
value === "md" ||
value === "lg" ||
value === "xl"
);
}
function isSidebarStyle(value: unknown): value is SidebarStyle {
return value === "floating" || value === "docked";
}
function readStoredAppearance(): Partial<AppearancePreferences> | 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,
fontPreference: isFontPreference(parsed.fontPreference)
? parsed.fontPreference
: 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:
typeof parsed.customColor === "string" ? 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:
parsed.pdfTemplate === "classic" || parsed.pdfTemplate === "minimal"
? parsed.pdfTemplate
: undefined,
pdfAccentColor:
typeof parsed.pdfAccentColor === "string"
? parsed.pdfAccentColor
: undefined,
pdfFooterText:
typeof parsed.pdfFooterText === "string"
? parsed.pdfFooterText
: undefined,
pdfShowLogo:
typeof parsed.pdfShowLogo === "boolean"
? parsed.pdfShowLogo
: undefined,
pdfShowPageNumbers:
typeof parsed.pdfShowPageNumbers === "boolean"
? parsed.pdfShowPageNumbers
: undefined,
};
} catch {
return null;
}
}
function writeStoredAppearance(prefs: AppearancePreferences) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// Storage can be unavailable in private browsing or locked-down contexts.
}
}
function applyAppearance(prefs: AppearancePreferences) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.dataset.interfaceTheme = prefs.interfaceTheme;
root.dataset.font = prefs.fontPreference;
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");
}
}
export function AppearanceProvider({
children,
}: {
children: React.ReactNode;
}) {
const [appearance, setAppearance] =
useState<AppearancePreferences>(defaultAppearance);
const utils = api.useUtils();
const updateMutation = api.settings.updateTheme.useMutation({
onSuccess: async () => {
await utils.settings.getTheme.invalidate();
},
onError: () => {
const cachedAppearance = utils.settings.getTheme.getData();
const fallback = cachedAppearance
? {
...defaultAppearance,
...getServerAppearancePatch(cachedAppearance),
}
: defaultAppearance;
setAppearance(fallback);
applyAppearance(fallback);
writeStoredAppearance(fallback);
},
});
const { 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) => {
setAppearance((prev) => {
const next = { ...prev, ...patch };
applyAppearance(next);
writeStoredAppearance(next);
return next;
});
updateMutation.mutate({
interfaceTheme: patch.interfaceTheme,
fontPreference: patch.fontPreference,
bodyFontPreference: patch.bodyFontPreference,
headingFontPreference: patch.headingFontPreference,
radiusPreference: patch.radiusPreference,
sidebarStyle: patch.sidebarStyle,
theme: patch.colorMode,
colorTheme: patch.colorTheme,
customColor: patch.customColor,
brandName: patch.brandName,
brandTagline: patch.brandTagline,
brandLogoText: patch.brandLogoText,
brandIcon: patch.brandIcon,
pdfTemplate: patch.pdfTemplate,
pdfAccentColor: patch.pdfAccentColor,
pdfFooterText: patch.pdfFooterText,
pdfShowLogo: patch.pdfShowLogo,
pdfShowPageNumbers: patch.pdfShowPageNumbers,
});
},
[updateMutation],
);
const value = useMemo<AppearanceContextValue>(
() => ({
...appearance,
updateAppearance,
isUpdating: updateMutation.isPending,
}),
[appearance, updateAppearance, updateMutation.isPending],
);
return (
<AppearanceContext.Provider value={value}>
{children}
</AppearanceContext.Provider>
);
}
export function useAppearance() {
const ctx = useContext(AppearanceContext);
if (!ctx) {
throw new Error("useAppearance must be used within an AppearanceProvider");
}
return ctx;
}
+38 -4
View File
@@ -13,10 +13,7 @@ export const env = createEnv({
: z.string().optional(),
DATABASE_URL: z.string().url(),
BETTER_AUTH_URL: z.string().url().optional(),
RESEND_API_KEY:
process.env.NODE_ENV === "production"
? z.string().min(1)
: z.string().min(1).optional(),
RESEND_API_KEY: z.string().min(1).optional(),
RESEND_DOMAIN: z.string().optional(),
NODE_ENV: z
.enum(["development", "test", "production"])
@@ -26,6 +23,7 @@ export const env = createEnv({
AUTHENTIK_ISSUER: z.string().url().optional(),
AUTHENTIK_CLIENT_ID: z.string().optional(),
AUTHENTIK_CLIENT_SECRET: z.string().optional(),
AUTHENTIK_ORIGIN: z.string().url().optional(),
},
/**
@@ -37,6 +35,27 @@ export const env = createEnv({
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().optional(),
NEXT_PUBLIC_AUTHENTIK_ENABLED: z.coerce.boolean().optional(),
NEXT_PUBLIC_BRAND_NAME: z.string().optional(),
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", "shadcn", "minimal", "editorial"])
.optional(),
NEXT_PUBLIC_DEFAULT_FONT: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_BODY_FONT: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
NEXT_PUBLIC_DEFAULT_HEADING_FONT: z
.enum(["brand", "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(),
},
/**
@@ -54,9 +73,24 @@ export const env = createEnv({
AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER,
AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID,
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
AUTHENTIK_ORIGIN: process.env.AUTHENTIK_ORIGIN,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
NEXT_PUBLIC_AUTHENTIK_ENABLED: process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED,
NEXT_PUBLIC_BRAND_NAME: process.env.NEXT_PUBLIC_BRAND_NAME,
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
+53 -45
View File
@@ -5,55 +5,63 @@ import { genericOAuth } from "better-auth/plugins";
import { db } from "~/server/db";
import * as schema from "~/server/db/schema";
const authentikEnabled = Boolean(
process.env.AUTHENTIK_ISSUER &&
process.env.AUTHENTIK_CLIENT_ID &&
process.env.AUTHENTIK_CLIENT_SECRET,
);
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema: {
user: schema.users,
session: schema.sessions,
account: schema.accounts,
verification: schema.verificationTokens,
ssoProvider: schema.ssoProviders,
},
}),
trustedOrigins: [
"https://beenvoice.soconnor.dev",
"https://auth.soconnor.dev", // Authentik IdP for OIDC discovery
],
database: drizzleAdapter(db, {
provider: "pg",
schema: {
user: schema.users,
session: schema.sessions,
account: schema.accounts,
verification: schema.verificationTokens,
ssoProvider: schema.ssoProviders,
},
}),
trustedOrigins: [
"https://beenvoice.soconnor.dev",
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
],
...(authentikEnabled && {
accountLinking: {
enabled: true,
trustedProviders: ["authentik"],
enabled: true,
trustedProviders: ["authentik"],
},
emailAndPassword: {
enabled: true,
password: {
hash: async (password) => {
const bcrypt = await import("bcryptjs");
return bcrypt.hash(password, 12);
},
verify: async ({ hash, password }) => {
const bcrypt = await import("bcryptjs");
return bcrypt.compare(password, hash);
},
},
}),
emailAndPassword: {
enabled: true,
password: {
hash: async (password) => {
const bcrypt = await import("bcryptjs");
return bcrypt.hash(password, 12);
},
verify: async ({ hash, password }) => {
const bcrypt = await import("bcryptjs");
return bcrypt.compare(password, hash);
},
},
plugins: [
nextCookies(),
genericOAuth({
},
plugins: [
nextCookies(),
...(authentikEnabled
? [
genericOAuth({
config: [
{
providerId: "authentik",
clientId: process.env.AUTHENTIK_CLIENT_ID!,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
discoveryUrl: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
// Explicit endpoints to ensure correct routing in production
authorizationUrl: "https://auth.soconnor.dev/application/o/authorize/",
tokenUrl: "https://auth.soconnor.dev/application/o/token/",
userInfoUrl: "https://auth.soconnor.dev/application/o/userinfo/",
scopes: ["openid", "email", "profile"],
pkce: true,
},
{
providerId: "authentik",
clientId: process.env.AUTHENTIK_CLIENT_ID!,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
discoveryUrl: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
scopes: ["openid", "email", "profile"],
pkce: true,
},
],
}),
],
}),
]
: []),
],
});
+249
View File
@@ -0,0 +1,249 @@
import { env } from "~/env";
export type InterfaceTheme = "beenvoice" | "shadcn" | "minimal" | "editorial";
export type FontPreference = "brand" | "platform" | "inter" | "serif";
export type RadiusPreference = "none" | "sm" | "md" | "lg" | "xl";
export type SidebarStyle = "floating" | "docked";
export type ColorMode = "light" | "dark" | "system";
export type ColorTheme =
| "slate"
| "blue"
| "green"
| "rose"
| "orange"
| "custom";
export const interfaceThemes: {
value: InterfaceTheme;
label: string;
description: string;
}[] = [
{
value: "beenvoice",
label: "beenvoice",
description: "Opinionated brand system with expressive headings.",
},
{
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;
}
> = {
beenvoice: {
interfaceTheme: "beenvoice",
bodyFontPreference: "brand",
headingFontPreference: "brand",
colorTheme: "slate",
radiusPreference: "xl",
sidebarStyle: "floating",
},
shadcn: {
interfaceTheme: "shadcn",
bodyFontPreference: "inter",
headingFontPreference: "inter",
colorTheme: "slate",
radiusPreference: "md",
sidebarStyle: "docked",
},
minimal: {
interfaceTheme: "minimal",
bodyFontPreference: "platform",
headingFontPreference: "platform",
colorTheme: "slate",
radiusPreference: "sm",
sidebarStyle: "docked",
},
editorial: {
interfaceTheme: "editorial",
bodyFontPreference: "platform",
headingFontPreference: "serif",
colorTheme: "rose",
radiusPreference: "lg",
sidebarStyle: "floating",
},
};
export const fontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Brand",
description: "Inter body with Playfair headings.",
},
{
value: "platform",
label: "Platform",
description: "Native system fonts for the current OS.",
},
{
value: "inter",
label: "Inter",
description: "Inter for both body and headings.",
},
{
value: "serif",
label: "Editorial",
description: "Serif headings with system body text.",
},
];
export const bodyFontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Brand Sans",
description: "Inter body text for a clean product feel.",
},
{
value: "platform",
label: "Platform",
description: "Native system body text for the current OS.",
},
{
value: "inter",
label: "Inter",
description: "Inter body text, explicitly selected.",
},
{
value: "serif",
label: "Serif",
description: "Georgia-style body text for editorial deployments.",
},
];
export const headingFontPreferences: {
value: FontPreference;
label: string;
description: string;
}[] = [
{
value: "brand",
label: "Brand Serif",
description: "Playfair headings for the BeenVoice identity.",
},
{
value: "platform",
label: "Platform",
description: "Native system headings for a neutral app feel.",
},
{
value: "inter",
label: "Inter",
description: "Inter headings for a plain shadcn-style baseline.",
},
{
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." },
];
export const defaultInterfaceTheme: InterfaceTheme =
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? "beenvoice";
export const defaultFontPreference: FontPreference =
env.NEXT_PUBLIC_DEFAULT_FONT ?? "brand";
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 ?? "xl";
export const defaultSidebarStyle: SidebarStyle =
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? "floating";
export const brand = {
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 ?? "$",
};
+148 -42
View File
@@ -118,6 +118,26 @@ interface InvoiceData {
} | null> | null;
}
export interface PDFGenerationSettings {
pdfTemplate?: "classic" | "minimal";
pdfAccentColor?: string;
pdfFooterText?: string;
pdfShowLogo?: boolean;
pdfShowPageNumbers?: boolean;
}
const defaultPDFSettings: Required<PDFGenerationSettings> = {
pdfTemplate: "classic",
pdfAccentColor: "#111827",
pdfFooterText: "Professional Invoicing",
pdfShowLogo: true,
pdfShowPageNumbers: true,
};
function resolvePDFSettings(settings?: PDFGenerationSettings) {
return { ...defaultPDFSettings, ...settings };
}
const styles = StyleSheet.create({
page: {
flexDirection: "column",
@@ -668,11 +688,14 @@ function getColumnWidths(showRate: boolean) {
}
// Dense header component (first page)
const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
const DenseHeader: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => (
<View style={styles.denseHeader}>
<View style={styles.headerTop}>
<View style={styles.businessSection}>
<Text style={styles.businessName}>
<Text style={[styles.businessName, { color: settings.pdfAccentColor }]}>
{invoice.business?.name ?? "Your Business Name"}
</Text>
{invoice.business?.email && (
@@ -708,7 +731,9 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
</View>
<View style={styles.invoiceSection}>
<Text style={styles.invoiceTitle}>INVOICE</Text>
<Text style={[styles.invoiceTitle, { color: settings.pdfAccentColor }]}>
INVOICE
</Text>
<Text style={styles.invoiceNumber}>
{invoice.invoicePrefix ?? "#"}
{invoice.invoiceNumber}
@@ -771,9 +796,14 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
);
// Abridged header component (other pages)
const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
const AbridgedHeader: React.FC<{
invoice: InvoiceData;
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, settings }) => (
<View style={styles.abridgedHeader}>
<Text style={styles.abridgedBusinessName}>
<Text
style={[styles.abridgedBusinessName, { color: settings.pdfAccentColor }]}
>
{invoice.business?.name ?? "Your Business Name"}
</Text>
<View style={styles.abridgedInvoiceInfo}>
@@ -787,19 +817,49 @@ const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
);
// Table header component
const TableHeader: React.FC<{ showRate: boolean }> = ({ showRate }) => {
const TableHeader: React.FC<{
settings: Required<PDFGenerationSettings>;
showRate: boolean;
}> = ({ settings, showRate }) => {
const cols = getColumnWidths(showRate);
return (
<View style={styles.tableHeader}>
<View
style={[
styles.tableHeader,
settings.pdfTemplate === "minimal" ? { backgroundColor: "#ffffff" } : {},
]}
>
<Text style={[styles.tableHeaderCell, { width: cols.date }]}>Date</Text>
<Text style={[styles.tableHeaderCell, { width: cols.description }]}>
Description
</Text>
<Text style={[styles.tableHeaderCell, styles.tableHeaderHours, { width: cols.hours }]}>Hours</Text>
<Text
style={[
styles.tableHeaderCell,
styles.tableHeaderHours,
{ width: cols.hours },
]}
>
Hours
</Text>
{showRate && (
<Text style={[styles.tableHeaderCell, styles.tableHeaderRate]}>Rate</Text>
<Text
style={[
styles.tableHeaderCell,
styles.tableHeaderRate,
{ width: cols.rate },
]}
>
Rate
</Text>
)}
<Text style={[styles.tableHeaderCell, styles.tableHeaderAmount, { width: cols.amount }]}>
<Text
style={[
styles.tableHeaderCell,
styles.tableHeaderAmount,
{ width: cols.amount },
]}
>
Amount
</Text>
</View>
@@ -820,35 +880,40 @@ const NotesSection: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
);
};
const Footer: React.FC = () => (
const Footer: React.FC<{ settings: Required<PDFGenerationSettings> }> = ({
settings,
}) => (
<View style={styles.footer} fixed>
<View style={styles.footerLogo}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<Image
src="/beenvoice-logo.png"
style={{
width: 120,
height: 18,
marginRight: 8,
}}
/>
{settings.pdfShowLogo && (
<Image
src="/beenvoice-logo.png"
style={{
width: 120,
height: 18,
marginRight: 8,
}}
/>
)}
<Text
style={{
fontSize: 9,
fontFamily: "Frutiger",
color: "#6b7280",
marginLeft: 8,
marginLeft: settings.pdfShowLogo ? 8 : 0,
}}
>
Professional Invoicing
{settings.pdfFooterText}
</Text>
</View>
<Text
style={styles.pageNumber}
render={({ pageNumber, totalPages }) =>
`Page ${pageNumber} of ${totalPages}`
}
/>
{settings.pdfShowPageNumbers && (
<Text
style={styles.pageNumber}
render={({ pageNumber, totalPages }) =>
`Page ${pageNumber} of ${totalPages}`
}
/>
)}
</View>
);
@@ -856,7 +921,8 @@ const Footer: React.FC = () => (
const TotalsSection: React.FC<{
invoice: InvoiceData;
items: Array<NonNullable<InvoiceData["items"]>[0]>;
}> = ({ invoice, items }) => {
settings: Required<PDFGenerationSettings>;
}> = ({ invoice, items, settings }) => {
const currency = invoice.currency ?? "USD";
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
const taxAmount = (subtotal * invoice.taxRate) / 100;
@@ -864,7 +930,18 @@ const TotalsSection: React.FC<{
return (
<View style={styles.totalsContainer}>
<View style={styles.totalsBox}>
<View
style={[
styles.totalsBox,
settings.pdfTemplate === "minimal"
? {
backgroundColor: "#ffffff",
borderTop: "1px solid #e5e7eb",
paddingHorizontal: 0,
}
: {},
]}
>
<Text
style={{
fontSize: 11,
@@ -896,7 +973,12 @@ const TotalsSection: React.FC<{
<View style={styles.finalTotalRow}>
<Text style={styles.finalTotalLabel}>TOTAL:</Text>
<Text style={styles.finalTotalAmount}>
<Text
style={[
styles.finalTotalAmount,
{ color: settings.pdfAccentColor },
]}
>
{formatCurrency(total, currency)}
</Text>
</View>
@@ -910,7 +992,11 @@ const TotalsSection: React.FC<{
};
// Main PDF component
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
const InvoicePDF: React.FC<{
invoice: InvoiceData;
settings?: PDFGenerationSettings;
}> = ({ invoice, settings: inputSettings }) => {
const settings = resolvePDFSettings(inputSettings);
const items = invoice.items?.filter(Boolean) ?? [];
const currency = invoice.currency ?? "USD";
const showRate = new Set(items.map((item) => item?.rate)).size > 1;
@@ -928,15 +1014,15 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
<Page key={`page-${pageIndex}`} size="LETTER" style={styles.page}>
{/* Header */}
{isFirstPage ? (
<DenseHeader invoice={invoice} />
<DenseHeader invoice={invoice} settings={settings} />
) : (
<AbridgedHeader invoice={invoice} />
<AbridgedHeader invoice={invoice} settings={settings} />
)}
{/* Table */}
{hasItems && (
<View style={styles.tableContainer}>
<TableHeader showRate={showRate} />
<TableHeader settings={settings} showRate={showRate} />
{pageItems.map(
(item, index) =>
item && (
@@ -944,7 +1030,9 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
key={`${pageIndex}-${index}`}
style={[
styles.tableRow,
index % 2 === 0 ? styles.tableRowAlt : {},
settings.pdfTemplate === "classic" && index % 2 === 0
? styles.tableRowAlt
: {},
]}
>
<Text style={[styles.tableCell, styles.tableCellDate, { width: cols.date }]}>
@@ -963,7 +1051,13 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
{item.hours}
</Text>
{showRate && (
<Text style={[styles.tableCell, styles.tableCellRate]}>
<Text
style={[
styles.tableCell,
styles.tableCellRate,
{ width: cols.rate },
]}
>
{formatCurrency(item.rate, currency)}
</Text>
)}
@@ -982,12 +1076,16 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
{isLastPage && (
<View style={styles.bottomSection}>
{invoice.notes && <NotesSection invoice={invoice} />}
<TotalsSection invoice={invoice} items={items} />
<TotalsSection
invoice={invoice}
items={items}
settings={settings}
/>
</View>
)}
{/* Footer */}
<Footer />
<Footer settings={settings} />
</Page>
);
})}
@@ -996,7 +1094,10 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
};
// Export functions
export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
export async function generateInvoicePDF(
invoice: InvoiceData,
settings?: PDFGenerationSettings,
): Promise<void> {
try {
// Validate invoice data
if (!invoice) {
@@ -1012,7 +1113,9 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
}
// Generate PDF blob
const originalBlob = await pdf(<InvoicePDF invoice={invoice} />).toBlob();
const originalBlob = await pdf(
<InvoicePDF invoice={invoice} settings={settings} />,
).toBlob();
// Validate blob
if (!originalBlob || originalBlob.size === 0) {
@@ -1038,6 +1141,7 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
// Additional utility function for generating PDF without downloading
export async function generateInvoicePDFBlob(
invoice: InvoiceData,
settings?: PDFGenerationSettings,
): Promise<Blob> {
try {
// Validate invoice data
@@ -1054,7 +1158,9 @@ export async function generateInvoicePDFBlob(
}
// Generate PDF blob
const originalBlob = await pdf(<InvoicePDF invoice={invoice} />).toBlob();
const originalBlob = await pdf(
<InvoicePDF invoice={invoice} settings={settings} />,
).toBlob();
// Validate blob
if (!originalBlob || originalBlob.size === 0) {
+22 -10
View File
@@ -1,15 +1,12 @@
import { z } from "zod";
import { Resend } from "resend";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { invoices } from "~/server/db/schema";
import { invoices, platformSettings } from "~/server/db/schema";
import { eq } from "drizzle-orm";
import { env } from "~/env";
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
// Default Resend instance - will be overridden if business has custom API key
const defaultResend = new Resend(env.RESEND_API_KEY);
export const emailRouter = createTRPCRouter({
sendInvoice: protectedProcedure
.input(
@@ -56,7 +53,19 @@ export const emailRouter = createTRPCRouter({
// Generate PDF for attachment
let pdfBuffer: Buffer;
try {
const pdfBlob = await generateInvoicePDFBlob(invoice);
const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
});
const pdfBlob = await generateInvoicePDFBlob(invoice, {
pdfTemplate: settings?.pdfTemplate as
| "classic"
| "minimal"
| undefined,
pdfAccentColor: settings?.pdfAccentColor,
pdfFooterText: settings?.pdfFooterText,
pdfShowLogo: settings?.pdfShowLogo,
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
});
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
// Validate PDF was generated successfully
@@ -126,14 +135,17 @@ export const emailRouter = createTRPCRouter({
: invoice.business.name) ??
userName;
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
} else if (env.RESEND_DOMAIN) {
} else if (env.RESEND_API_KEY && env.RESEND_DOMAIN) {
// Use system Resend configuration
resendInstance = defaultResend;
resendInstance = new Resend(env.RESEND_API_KEY);
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";
} else {
// Fallback to business email if no configured domains
resendInstance = defaultResend;
fromEmail = invoice.business?.email ?? "noreply@yourdomain.com";
throw new Error(
"Email delivery is not configured. Add a Resend API key globally or on this business.",
);
}
// Prepare CC and BCC lists
+191 -19
View File
@@ -1,14 +1,48 @@
import { z } from "zod";
import { eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "~/server/api/trpc";
import {
users,
clients,
businesses,
invoices,
invoiceItems,
platformSettings,
} from "~/server/db/schema";
import {
defaultBodyFontPreference,
defaultFontPreference,
defaultHeadingFontPreference,
defaultInterfaceTheme,
defaultRadiusPreference,
defaultSidebarStyle,
type ColorMode,
type ColorTheme,
type FontPreference,
type InterfaceTheme,
type RadiusPreference,
type SidebarStyle,
} from "~/lib/branding";
async function requireAdmin(ctx: {
db: typeof import("~/server/db").db;
session: { user: { id: string } };
}) {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: { role: true },
});
if (user?.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
// Validation schemas for backup data
const ClientBackupSchema = z.object({
@@ -76,6 +110,37 @@ const BackupDataSchema = z.object({
});
export const settingsRouter = createTRPCRouter({
listAccounts: protectedProcedure.query(async ({ ctx }) => {
await requireAdmin(ctx);
return ctx.db.query.users.findMany({
columns: {
id: true,
name: true,
email: true,
role: true,
emailVerified: true,
createdAt: true,
},
orderBy: (users, { asc }) => [asc(users.createdAt)],
});
}),
updateAccountRole: protectedProcedure
.input(
z.object({
userId: z.string().min(1),
role: z.enum(["user", "admin"]),
}),
)
.mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
await ctx.db
.update(users)
.set({ role: input.role })
.where(eq(users.id, input.userId));
return { success: true };
}),
// Get user profile information
getProfile: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.users.findFirst({
@@ -85,6 +150,7 @@ export const settingsRouter = createTRPCRouter({
name: true,
email: true,
image: true,
role: true,
},
});
@@ -144,20 +210,41 @@ export const settingsRouter = createTRPCRouter({
}),
// Get theme preferences
getTheme: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
colorTheme: true,
customColor: true,
theme: true,
},
getTheme: publicProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.query.platformSettings.findFirst({
where: eq(platformSettings.id, "global"),
});
return {
colorTheme: (user?.colorTheme as "slate" | "blue" | "green" | "rose" | "orange" | "custom") ?? "slate",
customColor: user?.customColor ?? undefined,
theme: (user?.theme as "light" | "dark" | "system") ?? "system",
colorTheme: (settings?.colorTheme as ColorTheme) ?? "slate",
customColor: settings?.customColor ?? undefined,
theme: (settings?.theme as ColorMode) ?? "system",
interfaceTheme:
(settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme,
fontPreference: defaultFontPreference,
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 ?? "beenvoice",
brandTagline:
settings?.brandTagline ??
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: settings?.brandLogoText ?? "beenvoice",
brandIcon: settings?.brandIcon ?? "$",
pdfTemplate:
(settings?.pdfTemplate as "classic" | "minimal") ?? "classic",
pdfAccentColor: settings?.pdfAccentColor ?? "#111827",
pdfFooterText: settings?.pdfFooterText ?? "Professional Invoicing",
pdfShowLogo: settings?.pdfShowLogo ?? true,
pdfShowPageNumbers: settings?.pdfShowPageNumbers ?? true,
};
}),
@@ -165,20 +252,105 @@ export const settingsRouter = createTRPCRouter({
updateTheme: protectedProcedure
.input(
z.object({
colorTheme: z.enum(["slate", "blue", "green", "rose", "orange", "custom"]).optional(),
colorTheme: z
.enum(["slate", "blue", "green", "rose", "orange", "custom"])
.optional(),
customColor: z.string().optional(),
theme: z.enum(["light", "dark", "system"]).optional(),
interfaceTheme: z
.enum(["beenvoice", "shadcn", "minimal", "editorial"])
.optional(),
fontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
bodyFontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
headingFontPreference: z
.enum(["brand", "platform", "inter", "serif"])
.optional(),
radiusPreference: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
sidebarStyle: z.enum(["floating", "docked"]).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: z.enum(["classic", "minimal"]).optional(),
pdfAccentColor: z.string().min(4).max(50).optional(),
pdfFooterText: z.string().min(1).max(120).optional(),
pdfShowLogo: z.boolean().optional(),
pdfShowPageNumbers: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await requireAdmin(ctx);
await ctx.db
.update(users)
.set({
...(input.colorTheme && { colorTheme: input.colorTheme }),
...(input.customColor !== undefined && { customColor: input.customColor }),
...(input.theme && { theme: input.theme }),
.insert(platformSettings)
.values({
id: "global",
brandName: input.brandName ?? "beenvoice",
brandTagline:
input.brandTagline ??
"Simple and efficient invoicing for freelancers and small businesses",
brandLogoText: input.brandLogoText ?? "beenvoice",
brandIcon: input.brandIcon ?? "$",
colorTheme: input.colorTheme ?? "slate",
customColor: input.customColor,
theme: input.theme ?? "system",
interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme,
bodyFontPreference:
input.bodyFontPreference ?? defaultBodyFontPreference,
headingFontPreference:
input.headingFontPreference ?? defaultHeadingFontPreference,
radiusPreference: input.radiusPreference ?? defaultRadiusPreference,
sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle,
pdfTemplate: input.pdfTemplate ?? "classic",
pdfAccentColor: input.pdfAccentColor ?? "#111827",
pdfFooterText: input.pdfFooterText ?? "Professional Invoicing",
pdfShowLogo: input.pdfShowLogo ?? true,
pdfShowPageNumbers: input.pdfShowPageNumbers ?? true,
})
.where(eq(users.id, ctx.session.user.id));
.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,
}),
...(input.pdfFooterText && { pdfFooterText: input.pdfFooterText }),
...(input.pdfShowLogo !== undefined && {
pdfShowLogo: input.pdfShowLogo,
}),
...(input.pdfShowPageNumbers !== undefined && {
pdfShowPageNumbers: input.pdfShowPageNumbers,
}),
updatedAt: new Date(),
},
});
return { success: true };
}),
+101 -16
View File
@@ -36,7 +36,10 @@ const migrationsFolder = path.resolve(__dirname, "../../../drizzle");
const pool = new Pool({
connectionString: databaseUrl,
ssl: process.env.DB_DISABLE_SSL === "true" ? false : { rejectUnauthorized: false },
ssl:
process.env.DB_DISABLE_SSL === "true"
? false
: { rejectUnauthorized: false },
max: 1,
});
@@ -50,7 +53,11 @@ const db = drizzle(pool);
* so migrate() will re-run those migrations.
*/
async function baselineIfNeeded(client: Pool) {
const hasMigrationsTable = await tableExists(client, "drizzle", "__drizzle_migrations");
const hasMigrationsTable = await tableExists(
client,
"drizzle",
"__drizzle_migrations",
);
// Always ensure the drizzle schema + table exist
await client.query(`CREATE SCHEMA IF NOT EXISTS drizzle`);
@@ -63,18 +70,24 @@ async function baselineIfNeeded(client: Pool) {
`);
const { rows: entryRows } = await client.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations`
`SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations`,
);
const hasEntries = parseInt(entryRows[0]?.count ?? "0") > 0;
if (!hasMigrationsTable || !hasEntries) {
// No history at all — check if DB was previously set up via db:push
const dbAlreadyExists = await tableExists(client, "public", "beenvoice_account");
const dbAlreadyExists = await tableExists(
client,
"public",
"beenvoice_account",
);
if (!dbAlreadyExists) {
return; // Fresh DB — let migrate() run everything normally
}
console.log("[migrate] Existing database detected without migration history — baselining...");
console.log(
"[migrate] Existing database detected without migration history — baselining...",
);
await seedMigrationHistory(client);
return;
}
@@ -86,7 +99,7 @@ async function baselineIfNeeded(client: Pool) {
async function seedMigrationHistory(client: Pool) {
const journal = JSON.parse(
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8")
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8"),
) as { entries: { idx: number; tag: string; when: number }[] };
for (const entry of journal.entries) {
@@ -96,12 +109,13 @@ async function seedMigrationHistory(client: Pool) {
continue;
}
const sql = fs.readFileSync(
path.join(migrationsFolder, `${entry.tag}.sql`), "utf8"
path.join(migrationsFolder, `${entry.tag}.sql`),
"utf8",
);
const hash = crypto.createHash("sha256").update(sql).digest("hex");
await client.query(
`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`,
[hash, entry.when]
[hash, entry.when],
);
console.log(`[migrate] Baselined: ${entry.tag}`);
}
@@ -111,16 +125,17 @@ async function seedMigrationHistory(client: Pool) {
async function removeBogusEntries(client: Pool) {
// Get all recorded hashes
const { rows } = await client.query<{ id: number; hash: string }>(
`SELECT id, hash FROM drizzle.__drizzle_migrations ORDER BY id`
`SELECT id, hash FROM drizzle.__drizzle_migrations ORDER BY id`,
);
const journal = JSON.parse(
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8")
fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8"),
) as { entries: { idx: number; tag: string; when: number }[] };
for (const entry of journal.entries) {
const sql = fs.readFileSync(
path.join(migrationsFolder, `${entry.tag}.sql`), "utf8"
path.join(migrationsFolder, `${entry.tag}.sql`),
"utf8",
);
const expectedHash = crypto.createHash("sha256").update(sql).digest("hex");
const recorded = rows.find((r) => r.hash === expectedHash);
@@ -129,17 +144,29 @@ async function removeBogusEntries(client: Pool) {
// It's recorded — verify it's actually applied in the schema
const applied = await isMigrationApplied(client, entry.tag);
if (!applied) {
console.log(`[migrate] Removing bogus migration record for: ${entry.tag}`);
await client.query(`DELETE FROM drizzle.__drizzle_migrations WHERE id = $1`, [recorded.id]);
console.log(
`[migrate] Removing bogus migration record for: ${entry.tag}`,
);
await client.query(
`DELETE FROM drizzle.__drizzle_migrations WHERE id = $1`,
[recorded.id],
);
}
}
}
async function tableExists(client: Pool, schema: string, table: string): Promise<boolean> {
const { rows } = await client.query<{ count: string }>(`
async function tableExists(
client: Pool,
schema: string,
table: string,
): Promise<boolean> {
const { rows } = await client.query<{ count: string }>(
`
SELECT COUNT(*)::text AS count FROM information_schema.tables
WHERE table_schema = $1 AND table_name = $2
`, [schema, table]);
`,
[schema, table],
);
return parseInt(rows[0]?.count ?? "0") > 0;
}
@@ -170,10 +197,68 @@ async function isMigrationApplied(client: Pool, tag: string): Promise<boolean> {
`);
return parseInt(rows[0]?.count ?? "0") > 0;
}
if (tag === "0003_appearance_preferences") {
// 0003 adds appearance preferences to beenvoice_user
const { rows } = await client.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'beenvoice_user'
AND column_name = 'interfaceTheme'
`);
return parseInt(rows[0]?.count ?? "0") > 0;
}
if (tag === "0004_platform_appearance_controls") {
// 0004 adds platform-level appearance controls to beenvoice_user
const { rows } = await client.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'beenvoice_user'
AND column_name = 'sidebarStyle'
`);
return parseInt(rows[0]?.count ?? "0") > 0;
}
if (tag === "0005_platform_settings_and_roles") {
const hasRole = await columnExists(
client,
"public",
"beenvoice_user",
"role",
);
const hasPlatformSettings = await tableExists(
client,
"public",
"beenvoice_platform_setting",
);
return hasRole && hasPlatformSettings;
}
if (tag === "0006_pdf_generation_settings") {
return columnExists(
client,
"public",
"beenvoice_platform_setting",
"pdfTemplate",
);
}
// Unknown migration — assume not applied so it runs
return false;
}
async function columnExists(
client: Pool,
schema: string,
table: string,
column: string,
): Promise<boolean> {
const { rows } = await client.query<{ count: string }>(
`
SELECT COUNT(*)::text AS count FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2 AND column_name = $3
`,
[schema, table, column],
);
return parseInt(rows[0]?.count ?? "0") > 0;
}
console.log("[migrate] Running migrations from", migrationsFolder);
try {
+42
View File
@@ -35,6 +35,48 @@ export const users = createTable("user", (d) => ({
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(),
}));
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
.varchar({ length: 120 })
.default("Professional Invoicing")
.notNull(),
pdfShowLogo: d.boolean().default(true).notNull(),
pdfShowPageNumbers: d.boolean().default(true).notNull(),
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
}));
export const usersRelations = relations(users, ({ many }) => ({
+310 -4
View File
@@ -34,8 +34,155 @@
/* 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="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-inter), 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="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-inter), ui-sans-serif, system-ui, sans-serif;
}
:root[data-font="brand"]:not([data-body-font]) {
--app-font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-playfair), ui-serif, Georgia, 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-inter), ui-sans-serif, system-ui, sans-serif;
--app-font-heading: var(--font-inter), 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%;
--popover-foreground: 0 0% 98%;
--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%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--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%;
}
@media (prefers-color-scheme: dark) {
:root {
:root:not([data-color-mode="light"]) {
--background: 240 10% 3.9%;
/* #09090B */
--foreground: 0 0% 98%;
@@ -61,6 +208,65 @@
--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-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 {
@@ -84,8 +290,8 @@
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
--font-heading: var(--font-heading), ui-serif, Georgia, serif;
--font-sans: var(--app-font-sans), ui-sans-serif, system-ui, sans-serif;
--font-heading: var(--app-font-heading), ui-serif, Georgia, serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
--radius-sm: calc(var(--radius) - 4px);
@@ -114,6 +320,87 @@
}
@layer utilities {
:root[data-interface-theme="shadcn"] .brand-background,
:root[data-interface-theme="minimal"] .brand-background {
display: none;
}
: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="editorial"] .brand-background {
opacity: 0.55;
}
.animate-blob {
animation: blob 7s infinite;
}
@@ -135,6 +422,25 @@
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 {
@@ -153,4 +459,4 @@
100% {
transform: translate(0px, 0px) scale(1);
}
}
}