Archived
Unify the dashboard experience and retire the multi-theme engine so onboarding and day-to-day invoicing feel consistent and easier to maintain.
Shared layout, tabs, and sidebar timer; user onboarding and registration polish; settings danger zone and data export; chart and tRPC perf fixes; migrations for onboarding and dropped appearance columns. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { brand } from "~/lib/branding";
|
||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface LogoProps {
|
||||
@@ -25,10 +24,7 @@ function splitLogoText(logoText: string) {
|
||||
}
|
||||
|
||||
export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
||||
const appearance = useAppearance();
|
||||
const logoText = appearance.brandLogoText || brand.logoText;
|
||||
const icon = appearance.brandIcon || brand.icon;
|
||||
const [logoPrefix, logoSuffix] = splitLogoText(logoText);
|
||||
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
|
||||
const sizeClasses = {
|
||||
sm: "text-base",
|
||||
md: "text-xl",
|
||||
@@ -45,7 +41,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
||||
sizeClasses={sizeClasses}
|
||||
logoPrefix={logoPrefix}
|
||||
logoSuffix={logoSuffix}
|
||||
icon={icon}
|
||||
icon={brand.icon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +63,7 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
||||
transition={{ delay: 0.02, duration: 0.05, ease: "easeOut" }}
|
||||
className="text-primary font-bold tracking-tight"
|
||||
>
|
||||
{icon}
|
||||
{brand.icon}
|
||||
</motion.span>
|
||||
{size !== "icon" && (
|
||||
<>
|
||||
@@ -75,8 +71,8 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.03, duration: 0.05, ease: "easeOut" }}
|
||||
className="inline-block w-1" // Reduced from w-2 to w-1 (half space)
|
||||
></motion.span>
|
||||
className="inline-block w-1"
|
||||
/>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -125,7 +121,7 @@ function LogoContent({
|
||||
<span className="text-primary font-bold tracking-tight">{icon}</span>
|
||||
{size !== "icon" && (
|
||||
<>
|
||||
<span className="inline-block w-1"></span>
|
||||
<span className="inline-block w-1" />
|
||||
<span className="text-foreground font-bold tracking-tight">
|
||||
{logoPrefix}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { ResponsiveContainer } from "recharts";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ResponsiveChartProps {
|
||||
height?: number;
|
||||
className?: string;
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
export function ResponsiveChart({
|
||||
height = 256,
|
||||
className,
|
||||
children,
|
||||
}: ResponsiveChartProps) {
|
||||
return (
|
||||
<div className={cn("w-full min-w-0", className)}>
|
||||
<ResponsiveContainer width="100%" height={height} minWidth={0}>
|
||||
{children}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,9 @@ import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AddressForm } from "~/components/forms/address-form";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -408,7 +410,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
(mode === "edit" && isLoadingEmailConfig)
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-6 pb-32">
|
||||
<DashboardPage className="pb-32">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
@@ -430,21 +432,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6 pb-32">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title={mode === "edit" ? "Edit Business" : "Add Business"}
|
||||
description={
|
||||
mode === "edit"
|
||||
? "Update business information below"
|
||||
: "Enter business details below to add a new business."
|
||||
}
|
||||
variant="gradient"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -469,9 +470,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<form id="business-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
<form
|
||||
id="business-form"
|
||||
onSubmit={handleSubmit}
|
||||
className={cn("flex flex-col", dashboardGapClass)}
|
||||
>
|
||||
{/* Main Form Container - styled like data table */}
|
||||
<div className="space-y-4">
|
||||
{/* Basic Information */}
|
||||
@@ -902,7 +907,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
|
||||
<FloatingActionBar
|
||||
leftContent={
|
||||
|
||||
@@ -19,7 +19,9 @@ import { Label } from "~/components/ui/label";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { AddressForm } from "~/components/forms/address-form";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
@@ -237,7 +239,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
|
||||
if (mode === "edit" && isLoadingClient) {
|
||||
return (
|
||||
<div className="space-y-6 pb-32">
|
||||
<DashboardPage className="pb-32">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
@@ -259,21 +261,20 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6 pb-32">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-32">
|
||||
<DashboardPageHeader
|
||||
title={mode === "edit" ? "Edit Client" : "Add Client"}
|
||||
description={
|
||||
mode === "edit"
|
||||
? "Update client information below"
|
||||
: "Enter client details below to add a new client."
|
||||
}
|
||||
variant="gradient"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -298,9 +299,13 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<form id="client-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
<form
|
||||
id="client-form"
|
||||
onSubmit={handleSubmit}
|
||||
className={cn("flex flex-col", dashboardGapClass)}
|
||||
>
|
||||
{/* Main Form Container - styled like data table */}
|
||||
<div className="space-y-4">
|
||||
{/* Basic Information */}
|
||||
@@ -508,7 +513,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
|
||||
<FloatingActionBar
|
||||
leftContent={
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
|
||||
interface EmailPreviewProps {
|
||||
subject: string;
|
||||
@@ -89,10 +90,7 @@ export function EmailPreview({
|
||||
customMessage: customMessage,
|
||||
userName: invoice.business?.name ?? "Your Business",
|
||||
userEmail: fromEmail,
|
||||
baseUrl:
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://beenvoice.app",
|
||||
baseUrl: getAppUrl(),
|
||||
})
|
||||
: null;
|
||||
|
||||
|
||||
@@ -6,7 +6,18 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import {
|
||||
PageTabs,
|
||||
PageTabsContent,
|
||||
PageTabsList,
|
||||
PageTabsTrigger,
|
||||
} from "~/components/layout/page-tabs";
|
||||
import {
|
||||
DashboardPage,
|
||||
dashboardGridClass,
|
||||
} from "~/components/layout/dashboard-page";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -17,7 +28,6 @@ import {
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { InvoiceLineItems } from "./invoice-line-items";
|
||||
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
||||
import { EmailPreview } from "./email-preview";
|
||||
@@ -62,19 +72,17 @@ interface InvoiceFormProps {
|
||||
|
||||
function InvoiceFormSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 pb-8">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-8">
|
||||
<DashboardPageHeader
|
||||
title="Loading..."
|
||||
description="Loading invoice form"
|
||||
variant="gradient"
|
||||
/>
|
||||
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />{" "}
|
||||
{/* Tabs Skeleton */}
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />
|
||||
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
|
||||
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -462,8 +470,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-enter space-y-6 pb-8">
|
||||
<PageHeader
|
||||
<DashboardPage className="pb-8">
|
||||
<DashboardPageHeader
|
||||
title={
|
||||
invoiceId !== "new"
|
||||
? "Edit Invoice"
|
||||
@@ -476,7 +484,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
? "Set up a draft to clock time into later"
|
||||
: "Manage your invoice"
|
||||
}
|
||||
variant="gradient"
|
||||
>
|
||||
{invoiceId !== "new" && (
|
||||
<Button
|
||||
@@ -491,42 +498,28 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</DashboardPageHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
|
||||
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||
{/* TAB SELECTOR: w-full, p-1, visible background */}
|
||||
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1 lg:grid-cols-3">
|
||||
<TabsTrigger
|
||||
value="details"
|
||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||
>
|
||||
Details
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="items"
|
||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||
>
|
||||
Items
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="timesheet"
|
||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||
>
|
||||
Timesheet
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="preview"
|
||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm lg:hidden"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
dashboardGridClass,
|
||||
"lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]",
|
||||
)}
|
||||
>
|
||||
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="details">Details</PageTabsTrigger>
|
||||
<PageTabsTrigger value="items">Items</PageTabsTrigger>
|
||||
<PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
|
||||
<PageTabsTrigger value="preview" className="lg:hidden">
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
{/* DETAILS TAB */}
|
||||
<TabsContent
|
||||
<PageTabsContent
|
||||
value="details"
|
||||
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
|
||||
className={cn(dashboardGridClass, "lg:grid-cols-2")}
|
||||
>
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
@@ -770,13 +763,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
{/* ITEMS TAB */}
|
||||
<TabsContent
|
||||
value="items"
|
||||
className="mt-6 focus-visible:outline-none"
|
||||
>
|
||||
<PageTabsContent value="items">
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
@@ -826,13 +816,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
{/* TIMESHEET TAB */}
|
||||
<TabsContent
|
||||
value="timesheet"
|
||||
className="mt-6 focus-visible:outline-none"
|
||||
>
|
||||
<PageTabsContent value="timesheet">
|
||||
<Card className="min-h-[600px] w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2">
|
||||
@@ -850,37 +837,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="preview"
|
||||
className="mt-6 focus-visible:outline-none"
|
||||
>
|
||||
<Tabs
|
||||
<PageTabsContent value="preview">
|
||||
<PageTabs
|
||||
value={previewTab}
|
||||
onValueChange={setPreviewTab}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="bg-muted grid h-auto w-full grid-cols-2 rounded-xl p-1">
|
||||
<TabsTrigger
|
||||
value="pdf"
|
||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||
>
|
||||
PDF
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="email"
|
||||
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||
>
|
||||
Email
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="pdf">PDF</PageTabsTrigger>
|
||||
<PageTabsTrigger value="email">Email</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
|
||||
<TabsContent value="pdf" className="mt-6">
|
||||
<PageTabsContent value="pdf">
|
||||
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
|
||||
</TabsContent>
|
||||
</PageTabsContent>
|
||||
|
||||
<TabsContent value="email" className="mt-6">
|
||||
<PageTabsContent value="email">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2">
|
||||
@@ -928,10 +902,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-4 space-y-4">
|
||||
@@ -950,7 +924,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardPage>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
/** Vertical rhythm for dashboard pages — use with shell `gap-5`. */
|
||||
export const dashboardGapClass = "gap-5 md:gap-6";
|
||||
|
||||
/** Standard grid gap for dashboard cards and sections. */
|
||||
export const dashboardGridClass =
|
||||
"grid gap-5 md:gap-6";
|
||||
|
||||
/** Summary stat cards (2-up mobile, 4-up desktop). */
|
||||
export const dashboardStatGridClass =
|
||||
"grid grid-cols-2 gap-4 sm:grid-cols-4";
|
||||
|
||||
export function DashboardPage({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"page-enter mx-auto flex w-full max-w-7xl flex-col",
|
||||
dashboardGapClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardGrid({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(dashboardGridClass, className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardCardTitle({
|
||||
children,
|
||||
icon: Icon,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{Icon ? <Icon className="text-muted-foreground h-4 w-4" /> : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Sidebar } from "~/components/layout/sidebar";
|
||||
import {
|
||||
SidebarProvider,
|
||||
@@ -11,23 +12,29 @@ import { Menu } from "lucide-react";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||
import { OnboardingGuard } from "~/components/layout/onboarding-guard";
|
||||
|
||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
const { isCollapsed } = useSidebar();
|
||||
const { sidebarStyle } = useAppearance();
|
||||
const pathname = usePathname();
|
||||
const [isMobileOpen, setIsMobileOpen] = React.useState(false);
|
||||
const isOnboarding = pathname === "/dashboard/onboarding";
|
||||
|
||||
return (
|
||||
<div className="bg-dashboard relative flex min-h-screen">
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="hidden md:block">
|
||||
<Sidebar />
|
||||
</div>
|
||||
{!isOnboarding && (
|
||||
<div className="hidden md:block">
|
||||
<Sidebar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar (Sheet) */}
|
||||
<div className="dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden",
|
||||
isOnboarding && "hidden",
|
||||
)}
|
||||
>
|
||||
<Sheet open={isMobileOpen} onOpenChange={setIsMobileOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
@@ -40,9 +47,9 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
{/* Mobile Link / Logo */}
|
||||
<div className="ml-4 flex items-center gap-2">
|
||||
<div className="ml-4 flex min-w-0 flex-1 items-center gap-2">
|
||||
<Logo size="sm" />
|
||||
<ActiveTimerWidget compact />
|
||||
</div>
|
||||
<SheetContent side="left" className="w-72 p-0">
|
||||
<div className="sr-only">
|
||||
@@ -53,29 +60,15 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out",
|
||||
"md:ml-0",
|
||||
sidebarStyle === "floating"
|
||||
? isCollapsed
|
||||
? "md:ml-24"
|
||||
: "md:ml-[18rem]"
|
||||
: isCollapsed
|
||||
? "md:ml-16"
|
||||
: "md:ml-64",
|
||||
"min-h-screen min-w-0 flex-1 transition-all duration-300 ease-in-out md:ml-0",
|
||||
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
|
||||
)}
|
||||
>
|
||||
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
|
||||
<div className="mb-4 md:hidden">
|
||||
{/* Mobile Breadcrumbs could go here or be part of the page */}
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<ActiveTimerWidget />
|
||||
</div>
|
||||
{children}
|
||||
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
|
||||
<OnboardingGuard>{children}</OnboardingGuard>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface DashboardUserContextValue {
|
||||
isAdmin: boolean;
|
||||
needsOnboarding: boolean;
|
||||
}
|
||||
|
||||
const DashboardUserContext = createContext<DashboardUserContextValue>({
|
||||
isAdmin: false,
|
||||
needsOnboarding: false,
|
||||
});
|
||||
|
||||
export function DashboardUserProvider({
|
||||
isAdmin,
|
||||
needsOnboarding,
|
||||
children,
|
||||
}: DashboardUserContextValue & { children: React.ReactNode }) {
|
||||
return (
|
||||
<DashboardUserContext.Provider value={{ isAdmin, needsOnboarding }}>
|
||||
{children}
|
||||
</DashboardUserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDashboardUser() {
|
||||
return useContext(DashboardUserContext);
|
||||
}
|
||||
@@ -3,15 +3,11 @@
|
||||
import React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||
import { useSidebar } from "~/components/layout/sidebar-provider";
|
||||
|
||||
interface FloatingActionBarProps {
|
||||
/** Content to display on the left side */
|
||||
leftContent?: React.ReactNode;
|
||||
/** Action buttons to display on the right */
|
||||
children: React.ReactNode;
|
||||
/** Additional className for styling */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -21,19 +17,12 @@ export function FloatingActionBar({
|
||||
className,
|
||||
}: FloatingActionBarProps) {
|
||||
const { isCollapsed } = useSidebar();
|
||||
const { sidebarStyle } = useAppearance();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pb-safe-area-inset-bottom fixed right-0 bottom-4 left-0 z-50 transition-all duration-300 ease-in-out",
|
||||
sidebarStyle === "floating"
|
||||
? isCollapsed
|
||||
? "md:left-24"
|
||||
: "md:left-[18rem]"
|
||||
: isCollapsed
|
||||
? "md:left-16"
|
||||
: "md:left-64",
|
||||
isCollapsed ? "md:left-16" : "md:left-64",
|
||||
"animate-slide-in-bottom",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useDashboardUser } from "./dashboard-user-context";
|
||||
|
||||
export function OnboardingGuard({ children }: { children: React.ReactNode }) {
|
||||
const { needsOnboarding } = useDashboardUser();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const onOnboardingPage = pathname === "/dashboard/onboarding";
|
||||
|
||||
useEffect(() => {
|
||||
if (needsOnboarding && !onOnboardingPage) {
|
||||
router.replace("/dashboard/onboarding");
|
||||
}
|
||||
}, [needsOnboarding, onOnboardingPage, router]);
|
||||
|
||||
if (needsOnboarding && !onOnboardingPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { DashboardBreadcrumbs } from "~/components/navigation/dashboard-breadcrumbs";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
@@ -40,7 +41,7 @@ export function PageHeader({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`animate-fade-in-down mb-6 ${className}`}>
|
||||
<div className={cn("animate-fade-in-down", className)}>
|
||||
{variant === "large-gradient" || variant === "gradient" ? (
|
||||
<div className="platform-header-surface bg-card text-card-foreground relative overflow-hidden rounded-xl border shadow-sm">
|
||||
<div className="platform-header-gradient from-primary/5 pointer-events-none absolute inset-0 bg-gradient-to-br via-transparent to-transparent" />
|
||||
@@ -104,8 +105,9 @@ export function DashboardPageHeader({
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={description}
|
||||
variant="large-gradient"
|
||||
className={className}
|
||||
variant="gradient"
|
||||
className={cn("mb-0", className)}
|
||||
titleClassName="font-heading text-2xl font-semibold tracking-tight sm:text-3xl"
|
||||
>
|
||||
{children}
|
||||
</PageHeader>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "~/components/ui/tabs";
|
||||
import { dashboardGapClass, dashboardGridClass } from "~/components/layout/dashboard-page";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
/** Vertical rhythm inside tab panels — matches dashboard page sections. */
|
||||
export const pageTabsPanelClass = dashboardGapClass;
|
||||
|
||||
/** Grid for stacked cards inside a tab panel. */
|
||||
export const pageTabsGridClass = dashboardGridClass;
|
||||
|
||||
type PageTabsProps = React.ComponentPropsWithoutRef<typeof Tabs>;
|
||||
|
||||
export function PageTabs({ className, ...props }: PageTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
className={cn("flex flex-col", pageTabsPanelClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type PageTabsListProps = React.ComponentPropsWithoutRef<typeof TabsList>;
|
||||
|
||||
export function PageTabsList({ className, ...props }: PageTabsListProps) {
|
||||
return (
|
||||
<div className="-mx-1 overflow-x-auto px-1 pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"bg-muted/50 border-border/60 inline-flex h-10 w-max min-w-full gap-0.5 rounded-xl border p-1 sm:min-w-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TabsTrigger>) {
|
||||
return (
|
||||
<TabsTrigger
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background h-8 rounded-lg px-3.5 text-sm data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TabsContent>) {
|
||||
return (
|
||||
<TabsContent
|
||||
className={cn(
|
||||
"mt-0 flex flex-col focus-visible:ring-0 focus-visible:outline-none",
|
||||
pageTabsPanelClass,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { authClient } from "~/lib/auth-client";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
|
||||
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
|
||||
import { useSidebar } from "./sidebar-provider";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Logo } from "~/components/branding/logo";
|
||||
@@ -26,8 +26,9 @@ import {
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { getGravatarUrl } from "~/lib/gravatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
|
||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||
|
||||
interface SidebarProps {
|
||||
mobile?: boolean;
|
||||
@@ -37,8 +38,9 @@ interface SidebarProps {
|
||||
export function Sidebar({ mobile, onClose }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { data: session, isPending } = useAuthSession();
|
||||
const { isAdmin } = useDashboardUser();
|
||||
const { isCollapsed, toggleCollapse } = useSidebar();
|
||||
const { sidebarStyle } = useAppearance();
|
||||
const navSections = getNavigationForUser(isAdmin);
|
||||
|
||||
// If mobile, always expanded
|
||||
const collapsed = mobile ? false : isCollapsed;
|
||||
@@ -72,7 +74,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
||||
collapsed && "items-center",
|
||||
)}
|
||||
>
|
||||
{navigationConfig.map((section) => (
|
||||
{navSections.map((section) => (
|
||||
<div key={section.title}>
|
||||
{!collapsed && (
|
||||
<div className="text-muted-foreground/60 mb-2 px-2 text-xs font-semibold tracking-wider uppercase">
|
||||
@@ -163,6 +165,8 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActiveTimerWidget collapsed={collapsed} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 border-t pt-4",
|
||||
@@ -265,10 +269,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed z-30 hidden flex-col transition-all duration-300 ease-in-out md:flex",
|
||||
sidebarStyle === "floating"
|
||||
? "border-border/50 bg-background/80 top-4 bottom-4 left-4 rounded-3xl border shadow-xl backdrop-blur-xl"
|
||||
: "border-border bg-background top-0 bottom-0 left-0 rounded-none border-r shadow-none",
|
||||
"border-border bg-background fixed top-0 bottom-0 left-0 z-30 hidden flex-col rounded-none border-r shadow-none transition-all duration-300 ease-in-out md:flex",
|
||||
isCollapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { BrowserFrame } from "~/components/marketing/browser-frame";
|
||||
import { getAppHost } from "~/lib/app-url";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const appHost = getAppHost();
|
||||
|
||||
function MockSidebar({ active }: { active: "dashboard" | "invoices" | "time" }) {
|
||||
const items = [
|
||||
{ id: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
|
||||
@@ -87,7 +90,7 @@ export function InvoicesScreenshot({ className }: { className?: string }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<BrowserFrame className={className} url="beenvoice.app/dashboard/invoices">
|
||||
<BrowserFrame className={className} url={`${appHost}/dashboard/invoices`}>
|
||||
<div className="flex min-h-[280px] sm:min-h-[320px]">
|
||||
<MockSidebar active="invoices" />
|
||||
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
||||
@@ -134,7 +137,7 @@ export function InvoicesScreenshot({ className }: { className?: string }) {
|
||||
|
||||
export function TimeClockScreenshot({ className }: { className?: string }) {
|
||||
return (
|
||||
<BrowserFrame className={className} url="beenvoice.app/dashboard/time-clock">
|
||||
<BrowserFrame className={className} url={`${appHost}/dashboard/time-clock`}>
|
||||
<div className="flex min-h-[260px] sm:min-h-[300px]">
|
||||
<MockSidebar active="time" />
|
||||
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
||||
@@ -204,7 +207,7 @@ export function TimeClockScreenshot({ className }: { className?: string }) {
|
||||
|
||||
export function DashboardScreenshot({ className }: { className?: string }) {
|
||||
return (
|
||||
<BrowserFrame className={className} url="beenvoice.app/dashboard">
|
||||
<BrowserFrame className={className} url={`${appHost}/dashboard`}>
|
||||
<div className="flex min-h-[260px] sm:min-h-[300px]">
|
||||
<MockSidebar active="dashboard" />
|
||||
<div className="min-w-0 flex-1 p-4 sm:p-5">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getAppHost } from "~/lib/app-url";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function BrowserFrame({
|
||||
children,
|
||||
className,
|
||||
url = "beenvoice.app/dashboard",
|
||||
url = `${getAppHost()}/dashboard`,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
|
||||
@@ -6,7 +6,8 @@ import { usePathname } from "next/navigation";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { useAuthSession } from "~/hooks/use-auth-session";
|
||||
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
|
||||
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
|
||||
import { useDashboardUser } from "~/components/layout/dashboard-user-context";
|
||||
|
||||
interface SidebarTriggerProps {
|
||||
isOpen: boolean;
|
||||
@@ -16,6 +17,8 @@ interface SidebarTriggerProps {
|
||||
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
||||
const pathname = usePathname();
|
||||
const { isPending } = useAuthSession();
|
||||
const { isAdmin } = useDashboardUser();
|
||||
const navSections = getNavigationForUser(isAdmin);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -34,7 +37,7 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
||||
<div className="bg-background border-border absolute top-full right-0 left-0 z-40 mt-1 border-t">
|
||||
{/* Navigation content */}
|
||||
<nav className="flex flex-col p-4">
|
||||
{navigationConfig.map((section, sectionIndex) => (
|
||||
{navSections.map((section, sectionIndex) => (
|
||||
<div
|
||||
key={section.title}
|
||||
className={sectionIndex > 0 ? "mt-4" : ""}
|
||||
|
||||
@@ -177,7 +177,6 @@ export function AnimationPreferencesProviderSynced({
|
||||
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
|
||||
|
||||
if (localIsDefault || differs) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
performUpdate(
|
||||
{
|
||||
prefersReducedMotion: serverPrefs.prefersReducedMotion,
|
||||
@@ -187,12 +186,9 @@ export function AnimationPreferencesProviderSynced({
|
||||
);
|
||||
}
|
||||
serverHydratedRef.current = true;
|
||||
}, [
|
||||
serverPrefs,
|
||||
performUpdate,
|
||||
prefersReducedMotion,
|
||||
animationSpeedMultiplier,
|
||||
]);
|
||||
// One-time hydration from server after local storage is read.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serverPrefs]);
|
||||
|
||||
const updatePreferences = useCallback<
|
||||
AnimationPreferencesContextValue["updatePreferences"]
|
||||
|
||||
@@ -1,232 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isHslChannels } from "~/lib/appearance";
|
||||
import type { ColorMode, ColorTheme, FontPreference, InterfaceTheme, RadiusPreference, SidebarStyle } from "~/lib/branding";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { defaultColorMode, type ColorMode } from "~/lib/appearance";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
AppearanceContext,
|
||||
applyAppearance,
|
||||
applyColorMode,
|
||||
defaultAppearance,
|
||||
readStoredAppearance,
|
||||
writeStoredAppearance,
|
||||
readStoredColorMode,
|
||||
writeStoredColorMode,
|
||||
type AppearanceContextValue,
|
||||
type AppearancePatch,
|
||||
type AppearancePreferences,
|
||||
} from "~/components/providers/appearance-provider";
|
||||
|
||||
type ServerAppearance = {
|
||||
interfaceTheme: InterfaceTheme;
|
||||
bodyFontPreference: FontPreference;
|
||||
headingFontPreference: FontPreference;
|
||||
radiusPreference: RadiusPreference;
|
||||
sidebarStyle: SidebarStyle;
|
||||
theme: ColorMode;
|
||||
colorTheme: ColorTheme;
|
||||
customColor?: string;
|
||||
brandName: string;
|
||||
brandTagline: string;
|
||||
brandLogoText: string;
|
||||
brandIcon: string;
|
||||
pdfTemplate: AppearancePreferences["pdfTemplate"];
|
||||
pdfAccentColor: string;
|
||||
pdfFooterText: string;
|
||||
pdfShowLogo: boolean;
|
||||
pdfShowPageNumbers: boolean;
|
||||
};
|
||||
|
||||
function getServerAppearancePatch(
|
||||
serverAppearance: ServerAppearance,
|
||||
): AppearancePatch {
|
||||
return {
|
||||
interfaceTheme: serverAppearance.interfaceTheme,
|
||||
bodyFontPreference: serverAppearance.bodyFontPreference,
|
||||
headingFontPreference: serverAppearance.headingFontPreference,
|
||||
radiusPreference: serverAppearance.radiusPreference,
|
||||
sidebarStyle: serverAppearance.sidebarStyle,
|
||||
colorMode: serverAppearance.theme,
|
||||
colorTheme: serverAppearance.colorTheme,
|
||||
customColor: serverAppearance.customColor,
|
||||
brandName: serverAppearance.brandName,
|
||||
brandTagline: serverAppearance.brandTagline,
|
||||
brandLogoText: serverAppearance.brandLogoText,
|
||||
brandIcon: serverAppearance.brandIcon,
|
||||
pdfTemplate: serverAppearance.pdfTemplate,
|
||||
pdfAccentColor: serverAppearance.pdfAccentColor,
|
||||
pdfFooterText: serverAppearance.pdfFooterText,
|
||||
pdfShowLogo: serverAppearance.pdfShowLogo,
|
||||
pdfShowPageNumbers: serverAppearance.pdfShowPageNumbers,
|
||||
};
|
||||
}
|
||||
|
||||
/** Dashboard appearance provider with tRPC theme sync. Must render inside TRPCReactProvider. */
|
||||
/** Dashboard appearance provider with per-user color mode sync. */
|
||||
export function AppearanceProviderSynced({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [appearance, setAppearance] =
|
||||
useState<AppearancePreferences>(defaultAppearance);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingDebouncedPatchRef = useRef<AppearancePatch>({});
|
||||
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
|
||||
const serverHydratedRef = useRef(false);
|
||||
const utils = api.useUtils();
|
||||
const updateMutation = api.settings.updateTheme.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.settings.getTheme.invalidate();
|
||||
},
|
||||
const updateMutation = api.settings.updateColorMode.useMutation({
|
||||
onError: () => {
|
||||
const cachedAppearance = utils.settings.getTheme.getData();
|
||||
const fallback = cachedAppearance
|
||||
? {
|
||||
...defaultAppearance,
|
||||
...getServerAppearancePatch(cachedAppearance),
|
||||
}
|
||||
: defaultAppearance;
|
||||
|
||||
setAppearance(fallback);
|
||||
applyAppearance(fallback);
|
||||
writeStoredAppearance(fallback);
|
||||
const cached = utils.settings.getColorMode.getData();
|
||||
const fallback = cached?.colorMode ?? defaultColorMode;
|
||||
setColorMode(fallback);
|
||||
applyColorMode(fallback);
|
||||
writeStoredColorMode(fallback);
|
||||
},
|
||||
});
|
||||
|
||||
const persistAppearance = useCallback(
|
||||
(patch: AppearancePatch) => {
|
||||
if (
|
||||
patch.customColor !== undefined &&
|
||||
!isHslChannels(patch.customColor)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { data: serverColorMode } = api.settings.getColorMode.useQuery(
|
||||
undefined,
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
},
|
||||
);
|
||||
|
||||
updateMutation.mutate({
|
||||
interfaceTheme: patch.interfaceTheme,
|
||||
bodyFontPreference: patch.bodyFontPreference,
|
||||
headingFontPreference: patch.headingFontPreference,
|
||||
radiusPreference: patch.radiusPreference,
|
||||
sidebarStyle: patch.sidebarStyle,
|
||||
theme: patch.colorMode,
|
||||
colorTheme: patch.colorTheme,
|
||||
customColor: patch.customColor,
|
||||
brandName: patch.brandName,
|
||||
brandTagline: patch.brandTagline,
|
||||
brandLogoText: patch.brandLogoText,
|
||||
brandIcon: patch.brandIcon,
|
||||
pdfTemplate: patch.pdfTemplate,
|
||||
pdfAccentColor: patch.pdfAccentColor,
|
||||
pdfFooterText: patch.pdfFooterText,
|
||||
pdfShowLogo: patch.pdfShowLogo,
|
||||
pdfShowPageNumbers: patch.pdfShowPageNumbers,
|
||||
});
|
||||
useEffect(() => {
|
||||
const stored = readStoredColorMode();
|
||||
if (stored) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setColorMode(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverColorMode?.colorMode) return;
|
||||
if (serverHydratedRef.current) return;
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setColorMode(serverColorMode.colorMode);
|
||||
serverHydratedRef.current = true;
|
||||
}, [serverColorMode?.colorMode]);
|
||||
|
||||
useEffect(() => {
|
||||
applyColorMode(colorMode);
|
||||
writeStoredColorMode(colorMode);
|
||||
}, [colorMode]);
|
||||
|
||||
const updateAppearance = useCallback(
|
||||
(patch: AppearancePatch) => {
|
||||
if (!patch.colorMode) return;
|
||||
|
||||
setColorMode(patch.colorMode);
|
||||
applyColorMode(patch.colorMode);
|
||||
writeStoredColorMode(patch.colorMode);
|
||||
updateMutation.mutate({ colorMode: patch.colorMode });
|
||||
},
|
||||
[updateMutation],
|
||||
);
|
||||
|
||||
const { data: serverAppearance } = api.settings.getTheme.useQuery(undefined, {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const storedAppearance = readStoredAppearance();
|
||||
if (!storedAppearance) return;
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverAppearance) return;
|
||||
const next = getServerAppearancePatch(serverAppearance);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAppearance((prev) => ({ ...prev, ...next }));
|
||||
}, [serverAppearance]);
|
||||
|
||||
useEffect(() => {
|
||||
applyAppearance(appearance);
|
||||
writeStoredAppearance(appearance);
|
||||
}, [appearance]);
|
||||
|
||||
const updateAppearance = useCallback(
|
||||
(patch: AppearancePatch) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
if (Object.keys(pendingDebouncedPatchRef.current).length > 0) {
|
||||
persistAppearance(pendingDebouncedPatchRef.current);
|
||||
pendingDebouncedPatchRef.current = {};
|
||||
}
|
||||
|
||||
setAppearance((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
applyAppearance(next);
|
||||
writeStoredAppearance(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
persistAppearance(patch);
|
||||
},
|
||||
[persistAppearance],
|
||||
);
|
||||
|
||||
const updateAppearanceDebounced = useCallback(
|
||||
(patch: AppearancePatch) => {
|
||||
pendingDebouncedPatchRef.current = {
|
||||
...pendingDebouncedPatchRef.current,
|
||||
...patch,
|
||||
};
|
||||
|
||||
setAppearance((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
applyAppearance(next);
|
||||
writeStoredAppearance(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
persistAppearance(pendingDebouncedPatchRef.current);
|
||||
pendingDebouncedPatchRef.current = {};
|
||||
debounceTimerRef.current = null;
|
||||
}, 500);
|
||||
},
|
||||
[persistAppearance],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
pendingDebouncedPatchRef.current = {};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value = useMemo<AppearanceContextValue>(
|
||||
() => ({
|
||||
...appearance,
|
||||
...defaultAppearance,
|
||||
colorMode,
|
||||
updateAppearance,
|
||||
updateAppearanceDebounced,
|
||||
isUpdating: updateMutation.isPending,
|
||||
}),
|
||||
[
|
||||
appearance,
|
||||
updateAppearance,
|
||||
updateAppearanceDebounced,
|
||||
updateMutation.isPending,
|
||||
],
|
||||
[colorMode, updateAppearance, updateMutation.isPending],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,181 +8,53 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
fallbackAppearance,
|
||||
isColorMode,
|
||||
isColorTheme,
|
||||
isFontPreference,
|
||||
isHslChannels,
|
||||
isInterfaceTheme,
|
||||
isPdfTemplate,
|
||||
isRadiusPreference,
|
||||
isSidebarStyle,
|
||||
type PdfTemplate,
|
||||
} from "~/lib/appearance";
|
||||
import {
|
||||
defaultBodyFontPreference,
|
||||
defaultHeadingFontPreference,
|
||||
defaultInterfaceTheme,
|
||||
defaultRadiusPreference,
|
||||
defaultSidebarStyle,
|
||||
brand as defaultBrand,
|
||||
type ColorMode,
|
||||
type ColorTheme,
|
||||
type FontPreference,
|
||||
type InterfaceTheme,
|
||||
type RadiusPreference,
|
||||
type SidebarStyle,
|
||||
} from "~/lib/branding";
|
||||
import { defaultColorMode, isColorMode, type ColorMode } from "~/lib/appearance";
|
||||
|
||||
export type AppearancePreferences = {
|
||||
interfaceTheme: InterfaceTheme;
|
||||
bodyFontPreference: FontPreference;
|
||||
headingFontPreference: FontPreference;
|
||||
radiusPreference: RadiusPreference;
|
||||
sidebarStyle: SidebarStyle;
|
||||
colorMode: ColorMode;
|
||||
colorTheme: ColorTheme;
|
||||
customColor?: string;
|
||||
brandName: string;
|
||||
brandTagline: string;
|
||||
brandLogoText: string;
|
||||
brandIcon: string;
|
||||
pdfTemplate: PdfTemplate;
|
||||
pdfAccentColor: string;
|
||||
pdfFooterText: string;
|
||||
pdfShowLogo: boolean;
|
||||
pdfShowPageNumbers: boolean;
|
||||
};
|
||||
|
||||
export type AppearancePatch = Partial<AppearancePreferences>;
|
||||
|
||||
export type AppearanceContextValue = AppearancePreferences & {
|
||||
updateAppearance: (patch: AppearancePatch) => void;
|
||||
updateAppearanceDebounced: (patch: AppearancePatch) => void;
|
||||
isUpdating: boolean;
|
||||
};
|
||||
|
||||
export const STORAGE_KEY = "bv.appearance";
|
||||
|
||||
export const defaultAppearance: AppearancePreferences = {
|
||||
interfaceTheme: defaultInterfaceTheme,
|
||||
bodyFontPreference: defaultBodyFontPreference,
|
||||
headingFontPreference: defaultHeadingFontPreference,
|
||||
radiusPreference: defaultRadiusPreference,
|
||||
sidebarStyle: defaultSidebarStyle,
|
||||
colorMode: fallbackAppearance.colorMode,
|
||||
colorTheme: fallbackAppearance.colorTheme,
|
||||
brandName: defaultBrand.name,
|
||||
brandTagline: defaultBrand.tagline,
|
||||
brandLogoText: defaultBrand.logoText,
|
||||
brandIcon: defaultBrand.icon,
|
||||
pdfTemplate: fallbackAppearance.pdfTemplate,
|
||||
pdfAccentColor: fallbackAppearance.pdfAccentColor,
|
||||
pdfFooterText: fallbackAppearance.pdfFooterText,
|
||||
pdfShowLogo: fallbackAppearance.pdfShowLogo,
|
||||
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
|
||||
colorMode: defaultColorMode,
|
||||
};
|
||||
|
||||
export const AppearanceContext =
|
||||
createContext<AppearanceContextValue | null>(null);
|
||||
|
||||
export function readStoredAppearance(): Partial<AppearancePreferences> | null {
|
||||
export function readStoredColorMode(): ColorMode | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
|
||||
? parsed.interfaceTheme
|
||||
: undefined,
|
||||
bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
|
||||
? parsed.bodyFontPreference
|
||||
: isFontPreference(parsed.fontPreference)
|
||||
? parsed.fontPreference
|
||||
: undefined,
|
||||
headingFontPreference: isFontPreference(parsed.headingFontPreference)
|
||||
? parsed.headingFontPreference
|
||||
: isFontPreference(parsed.fontPreference)
|
||||
? parsed.fontPreference
|
||||
: undefined,
|
||||
radiusPreference: isRadiusPreference(parsed.radiusPreference)
|
||||
? parsed.radiusPreference
|
||||
: undefined,
|
||||
sidebarStyle: isSidebarStyle(parsed.sidebarStyle)
|
||||
? parsed.sidebarStyle
|
||||
: undefined,
|
||||
colorMode: isColorMode(parsed.colorMode) ? parsed.colorMode : undefined,
|
||||
colorTheme: isColorTheme(parsed.colorTheme)
|
||||
? parsed.colorTheme
|
||||
: undefined,
|
||||
customColor: isHslChannels(parsed.customColor)
|
||||
? parsed.customColor
|
||||
: undefined,
|
||||
brandName:
|
||||
typeof parsed.brandName === "string" ? parsed.brandName : undefined,
|
||||
brandTagline:
|
||||
typeof parsed.brandTagline === "string"
|
||||
? parsed.brandTagline
|
||||
: undefined,
|
||||
brandLogoText:
|
||||
typeof parsed.brandLogoText === "string"
|
||||
? parsed.brandLogoText
|
||||
: undefined,
|
||||
brandIcon:
|
||||
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
|
||||
pdfTemplate: isPdfTemplate(parsed.pdfTemplate)
|
||||
? parsed.pdfTemplate
|
||||
: undefined,
|
||||
pdfAccentColor:
|
||||
typeof parsed.pdfAccentColor === "string"
|
||||
? parsed.pdfAccentColor
|
||||
: undefined,
|
||||
pdfFooterText:
|
||||
typeof parsed.pdfFooterText === "string"
|
||||
? parsed.pdfFooterText
|
||||
: undefined,
|
||||
pdfShowLogo:
|
||||
typeof parsed.pdfShowLogo === "boolean"
|
||||
? parsed.pdfShowLogo
|
||||
: undefined,
|
||||
pdfShowPageNumbers:
|
||||
typeof parsed.pdfShowPageNumbers === "boolean"
|
||||
? parsed.pdfShowPageNumbers
|
||||
: undefined,
|
||||
};
|
||||
const parsed = JSON.parse(raw) as { colorMode?: unknown };
|
||||
return isColorMode(parsed.colorMode) ? parsed.colorMode : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredAppearance(prefs: AppearancePreferences) {
|
||||
export function writeStoredColorMode(colorMode: ColorMode) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ colorMode }));
|
||||
} catch {
|
||||
// Storage can be unavailable in private browsing or locked-down contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAppearance(prefs: AppearancePreferences) {
|
||||
export function applyColorMode(colorMode: ColorMode) {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const root = document.documentElement;
|
||||
root.dataset.interfaceTheme = prefs.interfaceTheme;
|
||||
root.dataset.bodyFont = prefs.bodyFontPreference;
|
||||
root.dataset.headingFont = prefs.headingFontPreference;
|
||||
root.dataset.radius = prefs.radiusPreference;
|
||||
root.dataset.sidebarStyle = prefs.sidebarStyle;
|
||||
root.dataset.colorMode = prefs.colorMode;
|
||||
root.dataset.colorTheme = prefs.colorTheme;
|
||||
|
||||
root.classList.toggle("dark", prefs.colorMode === "dark");
|
||||
|
||||
if (prefs.customColor) {
|
||||
root.style.setProperty("--custom-primary", prefs.customColor);
|
||||
} else {
|
||||
root.style.removeProperty("--custom-primary");
|
||||
}
|
||||
root.dataset.colorMode = colorMode;
|
||||
root.classList.toggle("dark", colorMode === "dark");
|
||||
}
|
||||
|
||||
/** Local-only appearance provider for marketing and auth pages (no tRPC). */
|
||||
@@ -191,48 +63,36 @@ export function AppearanceProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [appearance, setAppearance] =
|
||||
useState<AppearancePreferences>(defaultAppearance);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
|
||||
|
||||
useEffect(() => {
|
||||
const storedAppearance = readStoredAppearance();
|
||||
if (!storedAppearance) return;
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
|
||||
const stored = readStoredColorMode();
|
||||
if (stored) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setColorMode(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyAppearance(appearance);
|
||||
writeStoredAppearance(appearance);
|
||||
}, [appearance]);
|
||||
applyColorMode(colorMode);
|
||||
writeStoredColorMode(colorMode);
|
||||
}, [colorMode]);
|
||||
|
||||
const updateAppearance = useCallback((patch: AppearancePatch) => {
|
||||
setAppearance((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
applyAppearance(next);
|
||||
writeStoredAppearance(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateAppearanceDebounced = useCallback((patch: AppearancePatch) => {
|
||||
setAppearance((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
applyAppearance(next);
|
||||
writeStoredAppearance(next);
|
||||
return next;
|
||||
});
|
||||
if (patch.colorMode) {
|
||||
setColorMode(patch.colorMode);
|
||||
applyColorMode(patch.colorMode);
|
||||
writeStoredColorMode(patch.colorMode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AppearanceContextValue>(
|
||||
() => ({
|
||||
...appearance,
|
||||
colorMode,
|
||||
updateAppearance,
|
||||
updateAppearanceDebounced,
|
||||
isUpdating: false,
|
||||
}),
|
||||
[appearance, updateAppearance, updateAppearanceDebounced],
|
||||
[colorMode, updateAppearance],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,7 @@ const Tabs = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex flex-col gap-1", className)}
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -54,7 +54,7 @@ const TabsContent = React.forwardRef<
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user