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:24:43 -04:00
co-authored by Copilot
parent b582b6c88e
commit fbeca7cfee
39 changed files with 3388 additions and 977 deletions
+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;
}