Polish onboarding, invoices, and time clock while promoting the first registrant to admin.

Refresh onboarding wizard and shell, tighten invoice edit/detail flows, align timer widgets with the redesigned clock panel, and assign admin role on first signup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 03:40:25 -04:00
co-authored by Cursor
parent c53f2e6c4d
commit 3fb61ff4dd
37 changed files with 1135 additions and 481 deletions
+4
View File
@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "~/lib/auth";
import { getDatabaseSetupErrorMessage } from "~/lib/db-errors";
import { resolveNewUserRole } from "~/lib/first-admin";
import { env } from "~/env";
import { db } from "~/server/db";
import { accounts, users } from "~/server/db/schema";
@@ -119,12 +120,15 @@ export async function POST(request: NextRequest) {
const hashedPassword = await bcrypt.hash(password, 12);
await db.transaction(async (tx) => {
const role = await resolveNewUserRole(tx);
const [user] = await tx
.insert(users)
.values({
name: `${firstName} ${lastName}`,
email: normalizedEmail,
password: hashedPassword,
role,
})
.returning({ id: users.id });
@@ -7,7 +7,11 @@ import { Card, CardContent } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Square, Clock } from "lucide-react";
import { toast } from "sonner";
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
import {
describeClockOutOutcome,
formatElapsedSeconds,
formatRunningTimerLabel,
} from "~/lib/time-clock";
import {
Tooltip,
TooltipContent,
@@ -86,10 +90,7 @@ export function ActiveTimerWidget({
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: null;
const description =
running.description || (
<span className="text-muted-foreground italic">No description</span>
);
const description = formatRunningTimerLabel(running.description);
const renderStopButton = (className?: string) => (
<Button
@@ -106,20 +107,20 @@ export function ActiveTimerWidget({
if (compact) {
return (
<div className="ml-auto flex flex-col items-center gap-1">
<div className="ml-auto flex min-w-0 items-center gap-1.5">
<Link
href="/dashboard/time-clock"
className="border-primary/30 bg-primary/5 flex items-center gap-2 rounded-md border px-2.5 py-1.5"
className="border-primary/30 bg-primary/5 flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1"
>
<span className="relative flex h-2 w-2 flex-shrink-0">
<span className="relative flex h-2 w-2 shrink-0">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full" />
</span>
<span className="text-primary font-mono text-sm font-bold tabular-nums">
<span className="text-primary truncate font-mono text-sm font-bold tabular-nums">
{formatElapsedSeconds(elapsed)}
</span>
</Link>
{renderStopButton()}
{renderStopButton("shrink-0")}
</div>
);
}
@@ -141,7 +142,10 @@ export function ActiveTimerWidget({
</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-56 space-y-2 p-3">
<TooltipContent
side="right"
className="bg-popover text-popover-foreground border-border max-w-56 space-y-2 border p-3 text-sm [&>svg]:bg-popover [&>svg]:fill-popover"
>
<p className="text-sm font-medium">
{description}
{running.client && (
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button";
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
import { Building, Pencil, Trash2, ExternalLink } from "lucide-react";
import { Building, Pencil, Trash2, ExternalLink, Plus } from "lucide-react";
import { useState } from "react";
import {
Dialog,
@@ -208,6 +208,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
data={searchableBusinesses}
searchKey="searchValue"
searchPlaceholder="Search by name or nickname..."
emptyTitle="Create your first business"
emptyDescription="Set up a business profile for invoices, branding, and tax details."
emptyIcon={<Building className="h-6 w-6" />}
emptyAction={
<Button asChild>
<Link href="/dashboard/businesses/new">
<Plus className="mr-2 h-4 w-4" />
Add business
</Link>
</Button>
}
onRowClick={handleRowClick}
/>
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button";
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
import { UserPlus, Pencil, Trash2 } from "lucide-react";
import { UserPlus, Pencil, Trash2, Plus, Users } from "lucide-react";
import { useState } from "react";
import {
Dialog,
@@ -179,6 +179,17 @@ export function ClientsDataTable({
data={clients}
searchKey="name"
searchPlaceholder="Search clients..."
emptyTitle="Create your first client"
emptyDescription="Add clients to bill them and keep contact details in one place."
emptyIcon={<Users className="h-6 w-6" />}
emptyAction={
<Button asChild>
<Link href="/dashboard/clients/new">
<Plus className="mr-2 h-4 w-4" />
Add client
</Link>
</Button>
}
onRowClick={handleRowClick}
/>
+12 -6
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { api } from "~/trpc/react";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
import { EmptyState } from "~/components/layout/page-layout";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
@@ -214,12 +215,17 @@ export default function ExpensesPage() {
Loading
</div>
) : expenses.length === 0 ? (
<div className="p-8 text-center">
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground text-sm">
No expenses yet. Add your first expense.
</p>
</div>
<EmptyState
icon={<Receipt className="h-6 w-6" />}
title="Create your first expense"
description="Track billable costs, reimbursements, and tax-deductible spending."
action={
<Button onClick={handleOpen}>
<Plus className="mr-2 h-4 w-4" />
Add expense
</Button>
}
/>
) : (
<div className="divide-y">
{expenses.map((expense) => (
@@ -1,9 +1,6 @@
"use client";
import Link from "next/link";
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
import { Button } from "~/components/ui/button";
import { ExternalLink } from "lucide-react";
interface InvoiceTimerCardProps {
invoiceId: string;
@@ -12,18 +9,10 @@ interface InvoiceTimerCardProps {
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
return (
<div className="space-y-3">
<TimeClockPanel
compact
defaultClientId={clientId}
defaultInvoiceId={invoiceId}
/>
<Button variant="outline" size="sm" className="w-full" asChild>
<Link href={`/dashboard/time-clock?clientId=${clientId}&invoiceId=${invoiceId}`}>
Open full time clock
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</Link>
</Button>
</div>
<TimeClockPanel
compact
defaultClientId={clientId}
defaultInvoiceId={invoiceId}
/>
);
}
+17 -7
View File
@@ -1,12 +1,22 @@
"use client";
import { useParams } from "next/navigation";
import { redirect } from "next/navigation";
import InvoiceForm from "~/components/forms/invoice-form";
import { api } from "~/trpc/server";
export default function InvoiceFormPage() {
const params = useParams();
const id = params.id as string;
interface EditInvoicePageProps {
params: Promise<{ id: string }>;
}
export default async function EditInvoicePage({ params }: EditInvoicePageProps) {
const { id } = await params;
try {
const invoice = await api.invoices.getById({ id });
if (invoice.status !== "draft") {
redirect(`/dashboard/invoices/${id}?editBlocked=1`);
}
} catch {
redirect("/dashboard/invoices");
}
// Pass the actual id, let the form component handle the logic
return <InvoiceForm invoiceId={id} />;
}
+25 -13
View File
@@ -20,7 +20,7 @@ import {
User,
} from "lucide-react";
import Link from "next/link";
import { notFound, useParams, useRouter } from "next/navigation";
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { StatusBadge } from "~/components/data/status-badge";
@@ -89,6 +89,7 @@ function daysSince(date: Date) {
function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [recordPaymentOpen, setRecordPaymentOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false);
@@ -106,6 +107,13 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
api.payments.getByInvoice.useQuery({ invoiceId });
const utils = api.useUtils();
useEffect(() => {
if (searchParams.get("editBlocked") === "1") {
toast.error("Only draft invoices can be edited");
router.replace(`/dashboard/invoices/${invoiceId}`);
}
}, [searchParams, invoiceId, router]);
const invalidate = () => {
void utils.invoices.getById.invalidate({ id: invoiceId });
void utils.payments.getByInvoice.invalidate({ invoiceId });
@@ -236,12 +244,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
description="View and manage invoice information"
>
<PDFDownloadButton invoiceId={invoice.id} variant="outline" className="hover-lift" />
<Button asChild variant="default" className="hover-lift">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Edit className="mr-2 h-5 w-5" />
Edit
</Link>
</Button>
{storedStatus === "draft" ? (
<Button asChild variant="default" className="hover-lift">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Edit className="mr-2 h-5 w-5" />
Edit
</Link>
</Button>
) : null}
</DashboardPageHeader>
<div className={cn(dashboardGridClass, "lg:grid-cols-3")}>
@@ -549,12 +559,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button asChild variant="secondary" className="w-full">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Invoice
</Link>
</Button>
{storedStatus === "draft" ? (
<Button asChild variant="secondary" className="w-full">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Invoice
</Link>
</Button>
) : null}
{invoice.items && invoice.client && (
<PDFDownloadButton invoiceId={invoice.id} className="w-full" variant="secondary" />
@@ -31,6 +31,7 @@ import {
CheckCircle,
Send,
ChevronDown,
Plus,
} from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
@@ -266,16 +267,30 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
<Eye className="h-3.5 w-3.5" />
</Button>
</Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
{invoice.status === "draft" ? (
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
title="Edit invoice"
>
<Edit className="h-3.5 w-3.5" />
</Button>
</Link>
) : (
<Button
variant="ghost"
size="sm"
className="hover-scale h-8 w-8 p-0"
data-action-button="true"
disabled
title="Only draft invoices can be edited"
>
<Edit className="h-3.5 w-3.5" />
</Button>
</Link>
)}
<Button
variant="ghost"
size="sm"
@@ -322,6 +337,17 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
searchPlaceholder="Search invoices..."
initialSorting={[{ id: "issueDate", desc: true }]}
filterableColumns={filterableColumns}
emptyTitle="Create your first invoice"
emptyDescription="Send professional invoices and track payments from one place."
emptyIcon={<FileText className="h-6 w-6" />}
emptyAction={
<Button asChild>
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
Create invoice
</Link>
</Button>
}
onRowClick={(invoice) =>
router.push(`/dashboard/invoices/${invoice.id}`)
}
+1 -7
View File
@@ -4,7 +4,7 @@ import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { FileText, Plus, Upload } from "lucide-react";
import { Plus, Upload } from "lucide-react";
import { InvoicesDataTable } from "./_components/invoices-data-table";
import { DataTableSkeleton } from "~/components/data/data-table";
@@ -28,12 +28,6 @@ export default async function InvoicesPage() {
<span>Import CSV</span>
</Link>
</Button>
<Button asChild variant="outline" className="hover-lift shadow-sm">
<Link href="/dashboard/invoices/new?blank=1">
<FileText className="mr-2 h-5 w-5" />
<span>Blank invoice</span>
</Link>
</Button>
<Button asChild variant="default" className="hover-lift shadow-md">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-5 w-5" />
+13 -10
View File
@@ -15,6 +15,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { EmptyState } from "~/components/layout/page-layout";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
@@ -376,16 +377,18 @@ export default function RecurringInvoicesPage() {
</div>
) : (recurring ?? []).length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<RefreshCw className="text-muted-foreground h-10 w-10" />
<p className="text-muted-foreground text-sm">
No recurring invoices yet. Create one to automatically generate draft invoices on a
schedule.
</p>
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
<Plus className="mr-2 h-4 w-4" />
Create first recurring invoice
</Button>
<CardContent className="p-0">
<EmptyState
icon={<RefreshCw className="h-6 w-6" />}
title="Create your first recurring invoice"
description="Automatically generate draft invoices on a schedule you choose."
action={
<Button onClick={() => { setForm(defaultForm()); setCreateOpen(true); }}>
<Plus className="mr-2 h-4 w-4" />
Create recurring invoice
</Button>
}
/>
</CardContent>
</Card>
) : (
@@ -0,0 +1,30 @@
import { Logo } from "~/components/branding/logo";
import { brand } from "~/lib/branding";
import { cn } from "~/lib/utils";
export function OnboardingShell({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="bg-dashboard text-foreground flex min-h-screen flex-col px-5 py-8 sm:px-6 sm:py-10">
<div
className={cn(
"mx-auto flex w-full max-w-xl flex-1 flex-col justify-center",
className,
)}
>
<div className="mb-8 space-y-4 text-center">
<div className="flex justify-center">
<Logo size="lg" animated={false} />
</div>
<p className="text-muted-foreground text-sm leading-6">{brand.tagline}</p>
</div>
{children}
</div>
</div>
);
}
@@ -0,0 +1,92 @@
import { Check } from "lucide-react";
import { cn } from "~/lib/utils";
export const ONBOARDING_STEPS = [
{ id: "welcome", label: "Welcome" },
{ id: "business", label: "Business" },
{ id: "client", label: "Client" },
] as const;
export type OnboardingStepId = (typeof ONBOARDING_STEPS)[number]["id"] | "done";
function stepIndex(step: OnboardingStepId) {
if (step === "done") return ONBOARDING_STEPS.length;
return ONBOARDING_STEPS.findIndex((item) => item.id === step);
}
export function OnboardingStepIndicator({ step }: { step: OnboardingStepId }) {
const currentIndex = stepIndex(step);
return (
<nav aria-label="Setup progress" className="mb-8">
<ol className="mx-auto flex w-full max-w-md">
{ONBOARDING_STEPS.map((item, index) => {
const isComplete = currentIndex > index;
const isCurrent = currentIndex === index;
const isUpcoming = currentIndex < index;
const connectorComplete = currentIndex > index;
return (
<li key={item.id} className="flex flex-1 flex-col items-center">
<div className="flex w-full items-center">
{index > 0 && (
<div
className={cn(
"h-0.5 flex-1 rounded-full transition-colors",
connectorComplete || isCurrent
? "bg-primary"
: "bg-border/80",
)}
aria-hidden
/>
)}
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors",
isComplete &&
"border-primary bg-primary text-primary-foreground",
isCurrent &&
"border-primary bg-primary/10 text-primary ring-primary/20 ring-4",
isUpcoming &&
"border-border/80 bg-background/60 text-muted-foreground",
)}
aria-current={isCurrent ? "step" : undefined}
>
{isComplete ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<span>{index + 1}</span>
)}
</div>
{index < ONBOARDING_STEPS.length - 1 && (
<div
className={cn(
"h-0.5 flex-1 rounded-full transition-colors",
connectorComplete ? "bg-primary" : "bg-border/80",
)}
aria-hidden
/>
)}
</div>
<span
className={cn(
"mt-2 hidden text-xs font-medium sm:block",
isCurrent ? "text-foreground" : "text-muted-foreground",
)}
>
{item.label}
</span>
</li>
);
})}
</ol>
<p className="text-muted-foreground mt-4 text-center text-sm sm:hidden">
Step {Math.min(currentIndex + 1, ONBOARDING_STEPS.length)} of{" "}
{ONBOARDING_STEPS.length}
{step !== "done" && ONBOARDING_STEPS[currentIndex]
? ` · ${ONBOARDING_STEPS[currentIndex].label}`
: ""}
</p>
</nav>
);
}
@@ -1,28 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
ArrowRight,
Building2,
CheckCircle2,
Sparkles,
FileText,
Users,
} from "lucide-react";
import { toast } from "sonner";
import { marketingSurfaceClass } from "~/components/marketing/marketing-chrome";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { brand } from "~/lib/branding";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/react";
import {
OnboardingStepIndicator,
type OnboardingStepId,
} from "./onboarding-step-indicator";
type Step = "welcome" | "business" | "client" | "done";
type Step = OnboardingStepId;
function StepIcon({
icon: Icon,
className,
}: {
icon: React.ComponentType<{ className?: string }>;
className?: string;
}) {
return (
<div
className={cn(
"bg-primary/10 text-primary mb-5 inline-flex rounded-2xl p-3",
className,
)}
>
<Icon className="h-6 w-6" />
</div>
);
}
function OnboardingPanel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
marketingSurfaceClass,
"bg-card/80 px-6 py-8 sm:px-8 sm:py-10",
className,
)}
>
{children}
</div>
);
}
export function OnboardingWizard() {
const router = useRouter();
@@ -65,15 +104,18 @@ export function OnboardingWizard() {
}
}, [status?.completed, router]);
useEffect(() => {
if (!isLoading && status && !status.completed && step === "welcome") {
if (status.businessCount > 0 && status.clientCount > 0) {
setStep("done");
} else if (status.businessCount > 0) {
setStep("client");
}
const displayStep = useMemo((): Step => {
if (step !== "welcome" || !status || status.completed) {
return step;
}
}, [isLoading, status, step]);
if (status.businessCount > 0 && status.clientCount > 0) {
return "done";
}
if (status.businessCount > 0) {
return "client";
}
return step;
}, [step, status]);
function handleSkip() {
completeOnboarding.mutate();
@@ -115,169 +157,208 @@ export function OnboardingWizard() {
if (isLoading || status?.completed) {
return (
<div className="mx-auto flex min-h-[60vh] max-w-lg items-center justify-center">
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground text-sm">Loading</p>
</div>
);
}
return (
<div className="mx-auto flex min-h-[60vh] w-full max-w-lg flex-col justify-center py-8">
<div className="mb-6 flex items-center justify-center gap-2">
<Sparkles className="text-primary h-5 w-5" />
<p className="text-muted-foreground text-sm font-medium">
{step === "welcome" && "Step 1 of 3"}
{step === "business" && "Step 2 of 3"}
{step === "client" && "Step 3 of 3"}
{step === "done" && "All set"}
</p>
</div>
<div className="w-full">
{displayStep !== "done" && <OnboardingStepIndicator step={displayStep} />}
{step === "welcome" && (
<Card>
<CardHeader>
<CardTitle>Welcome to BeenVoice</CardTitle>
<CardDescription>
{displayStep === "welcome" && (
<OnboardingPanel>
<div className="text-center">
<p className="text-primary mb-3 text-sm font-medium tracking-wide uppercase">
Quick setup
</p>
<StepIcon icon={FileText} />
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Welcome to {brand.name}
</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
Let&apos;s set up the basics so you can send your first invoice.
This only takes a minute.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<Building2 className="text-primary mt-0.5 h-4 w-4 shrink-0" />
<p>Add the business you send invoices from</p>
</p>
</div>
<ul className="mt-8 space-y-4">
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
<Building2 className="h-4 w-4" />
</div>
<div className="flex items-start gap-3">
<Users className="text-primary mt-0.5 h-4 w-4 shrink-0" />
<p>Add your first client to bill</p>
<div>
<p className="text-sm font-medium">Add your business</p>
<p className="text-muted-foreground mt-0.5 text-sm leading-6">
The name and details that appear on invoices you send.
</p>
</div>
</li>
<li className="bg-background/50 border-border/50 flex items-start gap-3 rounded-xl border p-4">
<div className="bg-primary/10 text-primary shrink-0 rounded-lg p-2">
<Users className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium">Add your first client</p>
<p className="text-muted-foreground mt-0.5 text-sm leading-6">
Who you&apos;re billing you can add more details later.
</p>
</div>
</li>
</ul>
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
<Button className="h-11 flex-1" size="lg" onClick={() => setStep("business")}>
Get started
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button
variant="ghost"
className="h-11"
onClick={handleSkip}
disabled={completeOnboarding.isPending}
>
Skip for now
</Button>
</div>
</OnboardingPanel>
)}
{displayStep === "business" && (
<OnboardingPanel>
<div className="text-center">
<StepIcon icon={Building2} />
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Your business
</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
This appears on invoices as the sender name, logo, and contact
details.
</p>
</div>
<form onSubmit={handleBusinessSubmit} className="mt-8 space-y-5">
<div className="space-y-2">
<Label htmlFor="business-name">Business name</Label>
<Input
id="business-name"
value={businessName}
onChange={(e) => setBusinessName(e.target.value)}
placeholder="Acme Studio LLC"
className="h-11"
autoFocus
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button className="flex-1" onClick={() => setStep("business")}>
Get started
<Button
type="submit"
size="lg"
className="h-11 flex-1"
disabled={createBusiness.isPending}
>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
className="h-11"
onClick={handleSkip}
disabled={completeOnboarding.isPending}
>
Skip for now
</Button>
</div>
</CardContent>
</Card>
</form>
</OnboardingPanel>
)}
{step === "business" && (
<Card>
<CardHeader>
<CardTitle>Your business</CardTitle>
<CardDescription>
This appears on invoices as the sender name, logo, and contact
details.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleBusinessSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="business-name">Business name</Label>
<Input
id="business-name"
value={businessName}
onChange={(e) => setBusinessName(e.target.value)}
placeholder="Acme Studio LLC"
autoFocus
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
className="flex-1"
disabled={createBusiness.isPending}
>
Continue
</Button>
<Button type="button" variant="ghost" onClick={handleSkip}>
Skip for now
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{step === "client" && (
<Card>
<CardHeader>
<CardTitle>Your first client</CardTitle>
<CardDescription>
{displayStep === "client" && (
<OnboardingPanel>
<div className="text-center">
<StepIcon icon={Users} />
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
Your first client
</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
Who are you billing? You can add more details later.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleClientSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client-name">Client name</Label>
<Input
id="client-name"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="Acme Corp"
autoFocus
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
className="flex-1"
disabled={createClient.isPending}
>
Continue
</Button>
<Button type="button" variant="ghost" onClick={handleSkip}>
Skip for now
</Button>
</div>
</form>
</CardContent>
</Card>
</p>
</div>
<form onSubmit={handleClientSubmit} className="mt-8 space-y-5">
<div className="space-y-2">
<Label htmlFor="client-name">Client name</Label>
<Input
id="client-name"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="Acme Corp"
className="h-11"
autoFocus
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="submit"
size="lg"
className="h-11 flex-1"
disabled={createClient.isPending}
>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
className="h-11"
onClick={handleSkip}
>
Skip for now
</Button>
</div>
</form>
</OnboardingPanel>
)}
{step === "done" && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="text-primary h-5 w-5" />
You&apos;re ready to go
</CardTitle>
<CardDescription>
Your workspace is set up. Create an invoice or explore the
dashboard.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2 sm:flex-row">
<Button className="flex-1" onClick={handleFinish}>
{displayStep === "done" && (
<OnboardingPanel className="text-center">
<div className="bg-primary/10 text-primary mx-auto mb-5 inline-flex rounded-full p-3">
<CheckCircle2 className="h-7 w-7" />
</div>
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
You&apos;re ready to go
</h1>
<p className="text-muted-foreground mx-auto mt-3 max-w-md text-sm leading-6 sm:text-base">
Your workspace is set up. Create an invoice or explore the dashboard.
</p>
<div className="mt-8 flex flex-col gap-2 sm:flex-row">
<Button size="lg" className="h-11 flex-1" onClick={handleFinish}>
Go to dashboard
</Button>
<Button variant="outline" className="flex-1" onClick={handleCreateInvoice}>
<Button
variant="outline"
size="lg"
className="h-11 flex-1"
onClick={handleCreateInvoice}
>
Create first invoice
</Button>
</CardContent>
</Card>
</div>
</OnboardingPanel>
)}
{step !== "welcome" && step !== "done" && (
<Button
variant="link"
className="text-muted-foreground mt-4"
onClick={() =>
setStep(step === "client" ? "business" : "welcome")
}
>
Back
</Button>
{step !== "welcome" && displayStep !== "done" && (
<div className="mt-6 text-center">
<Button
variant="link"
className="text-muted-foreground"
onClick={() =>
setStep(displayStep === "client" ? "business" : "welcome")
}
>
Back
</Button>
</div>
)}
</div>
);
+3 -3
View File
@@ -1,10 +1,10 @@
import { DashboardPage } from "~/components/layout/dashboard-page";
import { OnboardingShell } from "./_components/onboarding-shell";
import { OnboardingWizard } from "./_components/onboarding-wizard";
export default function OnboardingPage() {
return (
<DashboardPage>
<OnboardingShell>
<OnboardingWizard />
</DashboardPage>
</OnboardingShell>
);
}
+20 -1
View File
@@ -4,14 +4,33 @@ import { type Metadata } from "next";
import localFont from "next/font/local";
import { Toaster } from "~/components/ui/sonner";
import { getAppUrl } from "~/lib/app-url";
import { brand } from "~/lib/branding";
import { UmamiScript } from "~/components/analytics/umami-script";
import { BrandBackground } from "~/components/layout/brand-background";
const siteTitle = `${brand.name} - Invoicing Made Simple`;
export const metadata: Metadata = {
title: `${brand.name} - Invoicing Made Simple`,
metadataBase: new URL(getAppUrl()),
title: {
default: siteTitle,
template: `%s | ${brand.name}`,
},
description: brand.tagline,
openGraph: {
title: siteTitle,
description: brand.tagline,
siteName: brand.name,
type: "website",
locale: "en_US",
},
twitter: {
card: "summary_large_image",
title: siteTitle,
description: brand.tagline,
},
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
+132
View File
@@ -0,0 +1,132 @@
import { ImageResponse } from "next/og";
import { brand, splitLogoText } from "~/lib/branding";
export const alt = `${brand.name} - Invoicing Made Simple`;
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image() {
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
const geistMono = await fetch(
new URL(
"../../public/fonts/geist/mono/GeistMono-VariableFont_wght.ttf",
import.meta.url,
),
).then((res) => res.arrayBuffer());
const playfair = await fetch(
new URL(
"../../node_modules/@fontsource-variable/playfair-display/files/playfair-display-latin-wght-normal.woff2",
import.meta.url,
),
).then((res) => res.arrayBuffer());
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#ffffff",
position: "relative",
}}
>
<div
style={{
position: "absolute",
inset: 0,
backgroundImage:
"linear-gradient(to right, rgba(128,128,128,0.07) 1px, transparent 1px), linear-gradient(to bottom, rgba(128,128,128,0.07) 1px, transparent 1px)",
backgroundSize: "24px 24px",
}}
/>
<div
style={{
position: "absolute",
width: 520,
height: 520,
borderRadius: "50%",
backgroundColor: "rgba(163, 163, 163, 0.25)",
filter: "blur(80px)",
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
padding: "0 80px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
fontFamily: "Geist Mono",
fontSize: 72,
fontWeight: 700,
letterSpacing: "-0.02em",
}}
>
<span style={{ color: "#18181b" }}>{brand.icon}</span>
<span style={{ width: 16 }} />
<span style={{ color: "#09090b" }}>{logoPrefix}</span>
<span style={{ color: "rgba(9, 9, 11, 0.7)" }}>{logoSuffix}</span>
</div>
<div
style={{
marginTop: 32,
fontFamily: "Playfair Display",
fontSize: 40,
fontWeight: 600,
color: "#09090b",
textAlign: "center",
letterSpacing: "-0.02em",
}}
>
Invoicing Made Simple
</div>
<div
style={{
marginTop: 16,
fontFamily: "Geist Mono",
fontSize: 22,
fontWeight: 400,
color: "#71717a",
textAlign: "center",
maxWidth: 900,
lineHeight: 1.4,
}}
>
{brand.tagline}
</div>
</div>
</div>
),
{
...size,
fonts: [
{
name: "Geist Mono",
data: geistMono,
style: "normal",
weight: 700,
},
{
name: "Playfair Display",
data: playfair,
style: "normal",
weight: 600,
},
],
},
);
}
+1 -14
View File
@@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { brand } from "~/lib/branding";
import { brand, splitLogoText } from "~/lib/branding";
import { cn } from "~/lib/utils";
interface LogoProps {
@@ -10,19 +10,6 @@ interface LogoProps {
animated?: boolean;
}
function splitLogoText(logoText: string) {
const voiceIndex = logoText.toLowerCase().indexOf("voice");
if (voiceIndex > 0) {
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
}
return [
logoText.slice(0, Math.ceil(logoText.length / 2)),
logoText.slice(Math.ceil(logoText.length / 2)),
] as const;
}
export function Logo({ className, size = "md", animated = true }: LogoProps) {
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
const sizeClasses = {
+66 -6
View File
@@ -24,10 +24,12 @@ import {
ChevronsRight,
Filter,
Search,
SearchX,
X,
} from "lucide-react";
import * as React from "react";
import { EmptyState } from "~/components/layout/page-layout";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import {
@@ -87,6 +89,41 @@ interface DataTableProps<TData, TValue> {
clearSelection: () => void,
) => React.ReactNode;
initialSorting?: SortingState;
/** Shown when the dataset is empty (no rows in DB). */
emptyTitle?: string;
emptyDescription?: string;
emptyIcon?: React.ReactNode;
emptyAction?: React.ReactNode;
/** Shown when filters/search hide all rows but data exists. */
filteredEmptyTitle?: string;
filteredEmptyDescription?: string;
}
export interface DataTableEmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
/** Centered empty state for data tables (reuses page EmptyState). */
export function DataTableEmptyState({
icon,
title,
description,
action,
className,
}: DataTableEmptyStateProps) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
className={cn("py-16", className)}
/>
);
}
export function DataTable<TData, TValue>({
@@ -106,6 +143,12 @@ export function DataTable<TData, TValue>({
onRowClick,
selectionActions,
initialSorting = [],
emptyTitle,
emptyDescription,
emptyIcon,
emptyAction,
filteredEmptyTitle = "No matches for your search",
filteredEmptyDescription = "Try adjusting your search or filters.",
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -190,6 +233,9 @@ export function DataTable<TData, TValue>({
}, [globalFilter]);
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
const filteredRowCount = table.getFilteredRowModel().rows.length;
const isDatasetEmpty = data.length === 0;
const isFilteredEmpty = !isDatasetEmpty && filteredRowCount === 0;
// Handle row click
const handleRowClick = (row: TData, event: React.MouseEvent) => {
@@ -419,12 +465,26 @@ export function DataTable<TData, TValue>({
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
<p className="text-muted-foreground">No results found</p>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-0">
{isDatasetEmpty && emptyTitle ? (
<DataTableEmptyState
icon={emptyIcon}
title={emptyTitle}
description={emptyDescription}
action={emptyAction}
/>
) : isFilteredEmpty ? (
<DataTableEmptyState
icon={<SearchX className="h-6 w-6" />}
title={filteredEmptyTitle}
description={filteredEmptyDescription}
/>
) : (
<div className="text-muted-foreground py-16 text-center text-sm">
No results found
</div>
)}
</TableCell>
</TableRow>
)}
+14 -3
View File
@@ -155,11 +155,22 @@ export function InvoiceList() {
<Eye className="h-4 w-4" />
</Button>
</Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm">
{invoice.status === "draft" ? (
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
) : (
<Button
variant="ghost"
size="sm"
disabled
title="Only draft invoices can be edited"
>
<Edit className="h-4 w-4" />
</Button>
</Link>
)}
<Button
variant="ghost"
size="sm"
+91 -107
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Label } from "~/components/ui/label";
@@ -11,11 +11,9 @@ import {
PageTabsContent,
PageTabsList,
PageTabsTrigger,
pageTabsGridClass,
} from "~/components/layout/page-tabs";
import {
DashboardPage,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { cn } from "~/lib/utils";
import {
@@ -72,13 +70,13 @@ interface InvoiceFormProps {
function InvoiceFormSkeleton() {
return (
<DashboardPage className="pb-8">
<DashboardPage>
<DashboardPageHeader
title="Loading..."
description="Loading invoice form"
/>
<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-10 w-full animate-pulse rounded-xl p-1" />
<div className={cn(pageTabsGridClass, "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>
@@ -103,7 +101,7 @@ function plainTextToHtml(value: string) {
.replace(/\n/g, "<br>");
}
function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
function createDefaultInvoiceFormData(): InvoiceFormData {
return {
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#",
@@ -117,30 +115,26 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
taxRate: 0,
currency: "USD",
defaultHourlyRate: null,
items: blank
? []
: [
{
id: crypto.randomUUID(),
date: new Date(),
description: "",
hours: 1,
rate: 0,
amount: 0,
},
],
items: [
{
id: crypto.randomUUID(),
date: new Date(),
description: "",
hours: 1,
rate: 0,
amount: 0,
},
],
};
}
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const isBlank = searchParams.get("blank") === "1";
const utils = api.useUtils();
// State
const [formData, setFormData] = useState<InvoiceFormData>(() =>
createDefaultInvoiceFormData(isBlank),
createDefaultInvoiceFormData(),
);
const [loading, setLoading] = useState(false);
@@ -148,15 +142,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState("details");
const [previewTab, setPreviewTab] = useState("pdf");
const [previewPinned, setPreviewPinned] = useState(false);
useEffect(() => {
const media = window.matchMedia("(min-width: 1024px)");
const update = () => setPreviewPinned(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
// Queries (Same as before)
const { data: clients, isLoading: loadingClients } =
@@ -187,7 +172,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}, [invoiceId]);
useEffect(() => {
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
// ... (Mapping logic same as before)
const mappedItems: InvoiceItem[] =
existingInvoice.items?.map((item) => ({
id: crypto.randomUUID(),
@@ -239,6 +223,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}
}, [invoiceId, existingInvoice, businesses, initialized]);
useEffect(() => {
if (
invoiceId &&
invoiceId !== "new" &&
existingInvoice &&
!loadingInvoice &&
existingInvoice.status !== "draft"
) {
toast.error("Only draft invoices can be edited");
router.replace(`/dashboard/invoices/${invoiceId}`);
}
}, [invoiceId, existingInvoice, loadingInvoice, router]);
const totals = React.useMemo(() => {
const subtotal = formData.items.reduce(
(sum, item) => sum + item.hours * item.rate,
@@ -464,26 +461,20 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
!initialized ||
loadingClients ||
loadingBusinesses ||
(invoiceId && invoiceId !== "new" && loadingInvoice)
(invoiceId && invoiceId !== "new" && loadingInvoice) ||
(invoiceId &&
invoiceId !== "new" &&
existingInvoice &&
existingInvoice.status !== "draft")
)
return <InvoiceFormSkeleton />;
return (
<>
<DashboardPage className="pb-8">
<DashboardPage>
<DashboardPageHeader
title={
invoiceId !== "new"
? "Edit Invoice"
: isBlank
? "Blank Invoice"
: "Create Invoice"
}
description={
isBlank
? "Set up a draft to clock time into later"
: "Manage your invoice"
}
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
description="Manage your invoice"
>
{invoiceId !== "new" && (
<Button
@@ -500,27 +491,17 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</Button>
</DashboardPageHeader>
<div
className={cn(
dashboardGridClass,
"lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]",
)}
>
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
<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
</PageTabsTrigger>
<PageTabsTrigger value="preview">Preview</PageTabsTrigger>
</PageTabsList>
{/* DETAILS TAB */}
<PageTabsContent
value="details"
className={cn(dashboardGridClass, "lg:grid-cols-2")}
>
<PageTabsContent value="details">
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
<Card className="h-full">
<CardHeader>
<CardTitle className="flex gap-2 text-base">
@@ -763,11 +744,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</div>
</PageTabsContent>
{/* ITEMS TAB */}
<PageTabsContent value="items">
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<div className={cn(pageTabsGridClass, "md:grid-cols-3")}>
<Card className="bg-primary/5 border-primary/20">
<CardContent className="flex items-center justify-between p-4">
<span className="text-muted-foreground">Total</span>
@@ -840,28 +822,48 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</PageTabsContent>
<PageTabsContent value="preview">
<PageTabs
value={previewTab}
onValueChange={setPreviewTab}
className="w-full"
>
<PageTabsList>
<PageTabsTrigger value="pdf">PDF</PageTabsTrigger>
<PageTabsTrigger value="email">Email</PageTabsTrigger>
</PageTabsList>
<PageTabsContent value="pdf">
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
</PageTabsContent>
<PageTabsContent value="email">
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
<Mail className="h-5 w-5" /> Email Preview
</CardTitle>
</CardHeader>
<CardContent>
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
Preview
</CardTitle>
<div className="bg-muted flex rounded-lg p-1 text-sm">
<button
type="button"
onClick={() => setPreviewTab("pdf")}
className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
previewTab === "pdf"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
>
PDF
</button>
<button
type="button"
onClick={() => setPreviewTab("email")}
className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
previewTab === "email"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
>
Email
</button>
</div>
</CardHeader>
<CardContent className="p-0">
{previewTab === "pdf" ? (
<InvoicePdfPreviewPanel
embedded
input={pdfPreviewInput}
enabled={activeTab === "preview" && previewTab === "pdf"}
/>
) : (
<div className="border-t p-6">
<EmailPreview
subject={`Invoice ${formData.invoiceNumber} from ${
selectedBusiness?.name ?? "Your Business"
@@ -900,30 +902,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
})),
}}
/>
</CardContent>
</Card>
</PageTabsContent>
</PageTabs>
</div>
)}
</CardContent>
</Card>
</PageTabsContent>
</PageTabs>
<aside className="hidden lg:block">
<div className="sticky top-4 space-y-4">
<InvoicePdfPreviewPanel
input={pdfPreviewInput}
enabled={previewPinned || activeTab === "preview"}
/>
<Card className="border-primary/20 bg-primary/5">
<CardContent className="flex items-center justify-between p-4">
<span className="text-muted-foreground text-sm">Invoice total</span>
<span className="font-mono text-2xl font-bold">
<CountUp value={totals.total} prefix="$" />
</span>
</CardContent>
</Card>
</div>
</aside>
</div>
</DashboardPage>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+3 -3
View File
@@ -155,7 +155,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
<div
ref={ref}
className={cn(
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
)}
>
<DatePicker
@@ -260,7 +260,7 @@ function MobileLineItem({
date={item.date}
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
size="sm"
className="w-[92px] shrink-0"
className="w-auto shrink-0"
inputClassName="h-8 px-2 text-xs"
disabled={readOnly}
/>
@@ -374,7 +374,7 @@ export function InvoiceLineItems({
) : null}
<AnimatePresence>
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
<span>Date</span>
<span>Description</span>
<span className="text-center">Hours</span>
@@ -37,6 +37,8 @@ type InvoicePdfPreviewPanelProps = {
enabled?: boolean;
className?: string;
heightClassName?: string;
/** Renders only the preview body (no card/header) for embedding in a parent pane. */
embedded?: boolean;
};
export function InvoicePdfPreviewPanel({
@@ -44,6 +46,7 @@ export function InvoicePdfPreviewPanel({
enabled = true,
className,
heightClassName = "h-[min(80vh,760px)]",
embedded = false,
}: InvoicePdfPreviewPanelProps) {
const previewReady = canPreview(input);
@@ -54,6 +57,48 @@ export function InvoicePdfPreviewPanel({
staleTime: 5_000,
});
const previewBody = (
<div
className={cn(
"bg-muted/20 overflow-hidden border-t",
heightClassName,
)}
>
{!previewReady ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Select a client and add descriptions for all line items to generate the
PDF preview.
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-destructive text-sm">{error.message}</p>
<Button type="button" variant="outline" size="sm" onClick={() => void refetch()}>
Try again
</Button>
</div>
) : isFetching && !pdfPreview ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-center text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Generating preview
</div>
) : pdfPreview ? (
<iframe
title="Invoice PDF preview"
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
className="h-full w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
PDF preview will appear here.
</div>
)}
</div>
);
if (embedded) {
return <div className={cn("overflow-hidden", className)}>{previewBody}</div>;
}
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader className="pb-3">
@@ -63,43 +108,7 @@ export function InvoicePdfPreviewPanel({
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div
className={cn(
"bg-muted/20 overflow-hidden border-t",
heightClassName,
)}
>
{!previewReady ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Select a client and add descriptions for all line items to generate the
PDF preview.
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-destructive text-sm">{error.message}</p>
<Button type="button" variant="outline" size="sm" onClick={() => void refetch()}>
Try again
</Button>
</div>
) : isFetching && !pdfPreview ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-center text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Generating preview
</div>
) : pdfPreview ? (
<iframe
title="Invoice PDF preview"
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
className="h-full w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
PDF preview will appear here.
</div>
)}
</div>
</CardContent>
<CardContent className="p-0">{previewBody}</CardContent>
</Card>
);
}
+9 -5
View File
@@ -31,7 +31,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<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",
"dashboard-mobile-header bg-background/80 border-border fixed top-0 right-0 left-0 z-50 flex min-h-16 items-center border-b px-3 backdrop-blur-md sm:px-4 md:hidden",
isOnboarding && "hidden",
)}
>
@@ -47,8 +47,8 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<div className="ml-4 flex min-w-0 flex-1 items-center gap-2">
<Logo size="sm" />
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
<Logo size="sm" className="shrink-0" />
<ActiveTimerWidget compact />
</div>
<SheetContent side="left" className="w-72 p-0">
@@ -67,9 +67,13 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
)}
>
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
{isOnboarding ? (
<OnboardingGuard>{children}</OnboardingGuard>
</div>
) : (
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
<OnboardingGuard>{children}</OnboardingGuard>
</div>
)}
</main>
</div>
);
+2 -2
View File
@@ -123,13 +123,13 @@ export function EmptyState({
return (
<div className={cn("py-12 text-center", className)}>
{icon && (
<div className="bg-muted/50 mx-auto mb-4 flex h-16 w-16 items-center justify-center">
<div className="bg-muted mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl p-3 [&_svg]:text-muted-foreground">
{icon}
</div>
)}
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
{description && (
<p className="text-muted-foreground mx-auto mb-4 max-w-sm">
<p className="text-muted-foreground mx-auto mb-4 max-w-sm text-sm">
{description}
</p>
)}
@@ -177,6 +177,7 @@ export function AnimationPreferencesProviderSynced({
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time server hydration after local storage
performUpdate(
{
prefersReducedMotion: serverPrefs.prefersReducedMotion,
+72 -39
View File
@@ -20,7 +20,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import { ChevronDown, Clock, ExternalLink, Play, Square } from "lucide-react";
import { ChevronDown, Clock, Play, Square } from "lucide-react";
import { toast } from "sonner";
import { cn } from "~/lib/utils";
import {
@@ -30,6 +30,7 @@ import {
import {
describeClockOutOutcome,
formatElapsedSeconds,
formatRunningTimerLabel,
resolveClockDescription,
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
@@ -52,6 +53,19 @@ function invoiceLabel(inv: {
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
}
function entryHref(entry: {
invoiceId: string | null;
clientId: string | null;
invoice?: { id: string } | null;
client?: { id: string } | null;
}): string | null {
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
const clientId = entry.clientId ?? entry.client?.id;
if (clientId) return `/dashboard/clients/${clientId}`;
return null;
}
function ClientChip({
label,
active,
@@ -278,8 +292,7 @@ export function TimeClockPanel({
}
const displayRate = running ? (running.rate ?? 0) : rate;
const runningTitle =
running?.description?.trim() ?? resolveClockDescription("");
const runningTitle = formatRunningTimerLabel(running?.description);
return (
<div className={compact ? "space-y-4" : "space-y-6"}>
@@ -478,7 +491,11 @@ export function TimeClockPanel({
id="clock-stop-note"
value={stopNote}
onChange={(e) => setStopNote(e.target.value)}
placeholder={running?.description || "Update description when you stop"}
placeholder={
running?.description?.trim()
? running.description
: "Update description when you stop"
}
/>
</div>
)}
@@ -517,49 +534,65 @@ export function TimeClockPanel({
<CardHeader>
<CardTitle className="text-base">Today&apos;s entries</CardTitle>
</CardHeader>
<CardContent className="divide-y">
<CardContent>
{todayEntries
.filter((e) => e.endedAt)
.map((entry) => (
<div
key={entry.id}
className="flex items-start justify-between gap-4 py-3 first:pt-0 last:pb-0"
>
<div className="min-w-0">
<p className="font-medium">
{entry.description || (
<span className="text-muted-foreground italic">No description</span>
.map((entry, index, entries) => {
const href = entryHref(entry);
const isLast = index === entries.length - 1;
const rowClassName = cn(
"flex items-start justify-between gap-4 py-3",
!isLast && "border-border border-b",
);
const content = (
<>
<div className="min-w-0">
<p className="font-medium">
{formatRunningTimerLabel(entry.description)}
</p>
<p className="text-muted-foreground text-sm">
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: entry.hours
? " · not on invoice"
: ""}
</p>
</div>
<div className="text-right text-sm">
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
{entry.rate ? (
<p className="text-muted-foreground">${entry.rate}/hr</p>
) : null}
</div>
</>
);
if (href) {
return (
<Link
key={entry.id}
href={href}
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
)}
</p>
<p className="text-muted-foreground text-sm">
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: entry.hours
? " · not on invoice"
: ""}
</p>
>
{content}
</Link>
);
}
return (
<div key={entry.id} className={rowClassName}>
{content}
</div>
<div className="text-right text-sm">
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
{entry.rate ? (
<p className="text-muted-foreground">${entry.rate}/hr</p>
) : null}
</div>
</div>
))}
);
})}
</CardContent>
</Card>
) : null}
{compact ? (
<Button variant="link" className="h-auto p-0" asChild>
<Link href="/dashboard/time-clock">
Open full time clock
<ExternalLink className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
) : null}
</div>
);
}
+29 -15
View File
@@ -14,18 +14,23 @@ import {
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
day: "2-digit",
month: "long",
year: "numeric",
};
function formatDate(date: Date | undefined) {
if (!date) {
return "";
}
return date.toLocaleDateString("en-US", {
day: "2-digit",
month: "long",
year: "numeric",
});
return date.toLocaleDateString("en-US", DATE_FORMAT_OPTIONS);
}
// Longest month name in en-US long format (September 30, 2026).
const LONGEST_FORMATTED_DATE = formatDate(new Date(2026, 8, 30));
interface DatePickerProps {
date?: Date;
onDateChange: (date: Date | undefined) => void;
@@ -57,13 +62,7 @@ export function DatePicker({
lg: "h-10 text-sm",
};
const inputWidthClass = className?.includes("w-full")
? "w-full"
: className?.includes("w-32") ||
className?.includes("w-28") ||
className?.includes("w-36")
? className
: "w-full md:w-32 md:min-w-32";
const wantsFullWidth = className?.includes("w-full");
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
@@ -72,16 +71,31 @@ export function DatePicker({
}, [date]);
return (
<div className={cn("relative flex gap-2", inputWidthClass, className)}>
<div
className={cn(
"relative min-w-max",
wantsFullWidth ? "w-full" : "w-auto shrink-0",
className,
)}
>
<span
aria-hidden
className={cn(
"invisible block whitespace-nowrap px-3 pr-10",
sizeClasses[size],
inputClassName,
)}
>
{LONGEST_FORMATTED_DATE}
</span>
<Input
id={id}
value={value}
placeholder={placeholder}
disabled={disabled}
className={cn(
"bg-background pr-10",
"bg-background absolute inset-0 w-full pr-10 tabular-nums",
sizeClasses[size],
"w-full",
inputClassName,
)}
onChange={(e) => {
+2 -2
View File
@@ -64,9 +64,9 @@ function SheetContent({
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto rounded-b-xl border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto rounded-t-xl border-t",
className,
)}
{...props}
+13
View File
@@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { genericOAuth } from "better-auth/plugins";
import { envBoolean } from "~/lib/env-boolean";
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
import { db } from "~/server/db";
import * as schema from "~/server/db/schema";
@@ -48,6 +49,18 @@ export const auth = betterAuth({
verification: schema.verificationTokens,
},
}),
databaseHooks: {
user: {
create: {
after: async (user) => {
if (isDemoUser(user)) {
return;
}
await promoteFirstRealUserIfNeeded(user.id);
},
},
},
},
trustedOrigins: async (request) => {
const origins = [...staticTrustedOrigins];
+15 -1
View File
@@ -1,5 +1,5 @@
import { env } from "~/env";
import { defaultColorMode, type ColorMode } from "~/lib/appearance";
import { type ColorMode } from "~/lib/appearance";
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
export {
@@ -39,3 +39,17 @@ export const brand = {
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
};
/** Split logo text for the `$ been` / `voice` styling used in Logo and OG images. */
export function splitLogoText(logoText: string) {
const voiceIndex = logoText.toLowerCase().indexOf("voice");
if (voiceIndex > 0) {
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
}
return [
logoText.slice(0, Math.ceil(logoText.length / 2)),
logoText.slice(Math.ceil(logoText.length / 2)),
] as const;
}
+82
View File
@@ -0,0 +1,82 @@
import { and, asc, eq, ne, sql } from "drizzle-orm";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
/** Seeded in drizzle/0014_seed_demo_account.sql for App Store review. */
export const DEMO_USER_EMAIL = "demo@example.com";
export const DEMO_USER_ID = "a0000000-0000-4000-8000-000000000001";
const FIRST_USER_ADMIN_LOCK_KEY = 0x62656e76;
type DbTx = Pick<typeof db, "execute" | "select" | "query" | "update">;
export function isDemoUser(user: {
email?: string | null;
id?: string | null;
}): boolean {
const email = user.email?.toLowerCase();
return email === DEMO_USER_EMAIL || user.id === DEMO_USER_ID;
}
function nonDemoUserConditions() {
return and(ne(users.email, DEMO_USER_EMAIL), ne(users.id, DEMO_USER_ID));
}
async function acquireFirstUserAdminLock(tx: DbTx): Promise<void> {
await tx.execute(
sql`SELECT pg_advisory_xact_lock(${FIRST_USER_ADMIN_LOCK_KEY})`,
);
}
/**
* Role for a user about to be inserted. Call inside a transaction before insert.
*/
export async function resolveNewUserRole(tx: DbTx): Promise<"admin" | "user"> {
await acquireFirstUserAdminLock(tx);
const [result] = await tx
.select({ count: sql<number>`count(*)::int` })
.from(users)
.where(nonDemoUserConditions());
return (result?.count ?? 0) === 0 ? "admin" : "user";
}
/**
* Promote the first non-demo user to admin after Better Auth creates them (OAuth, etc.).
* Safe under concurrent sign-ups: only one non-demo admin is ever assigned.
*/
export async function promoteFirstRealUserIfNeeded(userId: string): Promise<void> {
await db.transaction(async (tx) => {
await acquireFirstUserAdminLock(tx);
const user = await tx.query.users.findFirst({
where: eq(users.id, userId),
columns: { id: true, email: true, role: true },
});
if (!user || isDemoUser(user)) {
return;
}
const [adminResult] = await tx
.select({ count: sql<number>`count(*)::int` })
.from(users)
.where(and(nonDemoUserConditions(), eq(users.role, "admin")));
if ((adminResult?.count ?? 0) > 0) {
return;
}
const [firstRealUser] = await tx
.select({ id: users.id })
.from(users)
.where(nonDemoUserConditions())
.orderBy(asc(users.createdAt))
.limit(1);
if (firstRealUser?.id === userId) {
await tx.update(users).set({ role: "admin" }).where(eq(users.id, userId));
}
});
}
+17 -2
View File
@@ -1,4 +1,5 @@
export const DEFAULT_CLOCK_DESCRIPTION = "Professional services";
/** Stored on entries clocked in before empty descriptions were allowed. */
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
export function resolveEffectiveHourlyRate(
enteredRate: number,
@@ -21,7 +22,21 @@ export function resolveClockDescription(
const trimmed = title.trim();
if (trimmed) return trimmed;
if (existingDescription?.trim()) return existingDescription.trim();
return DEFAULT_CLOCK_DESCRIPTION;
return "";
}
export function formatRunningTimerLabel(description?: string | null): string {
const trimmed = description?.trim() ?? "";
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) return "Clocked in";
return trimmed;
}
export function resolveBillingDescription(description?: string | null): string {
const trimmed = description?.trim() ?? "";
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) {
return LEGACY_DEFAULT_CLOCK_DESCRIPTION;
}
return trimmed;
}
export type ClockOutOutcome =
+2 -12
View File
@@ -440,20 +440,10 @@ export const invoicesRouter = createTRPCRouter({
});
}
if (items && existingInvoice.status !== "draft") {
if (existingInvoice.status !== "draft") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Line items can only be edited on draft invoices",
});
}
if (
cleanInvoiceData.sendReminderAt !== undefined &&
existingInvoice.status !== "draft"
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Send reminders can only be set on draft invoices",
message: "Only draft invoices can be edited",
});
}
+6 -4
View File
@@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server";
import type { db } from "~/server/db";
import {
computeTrackedHours,
resolveBillingDescription,
type ClockOutOutcome,
} from "~/lib/time-clock";
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
@@ -457,12 +458,13 @@ export const timeEntriesRouter = createTRPCRouter({
const endedAt = new Date();
const hours = computeHours(entry.startedAt, endedAt);
const description = input?.description?.trim() ?? entry.description;
const rawDescription = input?.description?.trim() ?? entry.description?.trim() ?? "";
const billingDescription = resolveBillingDescription(rawDescription);
const rate = entry.rate ?? 0;
const [updated] = await ctx.db
.update(timeEntries)
.set({ endedAt, hours, description, updatedAt: new Date() })
.set({ endedAt, hours, description: rawDescription, updatedAt: new Date() })
.where(eq(timeEntries.id, entry.id))
.returning();
@@ -478,7 +480,7 @@ export const timeEntriesRouter = createTRPCRouter({
ctx.session.user.id,
entry.invoiceId,
updated.id,
description,
billingDescription,
hours,
rate,
endedAt,
@@ -490,7 +492,7 @@ export const timeEntriesRouter = createTRPCRouter({
ctx.session.user.id,
entry.clientId,
updated.id,
description,
billingDescription,
hours,
rate,
endedAt,
+13 -2
View File
@@ -26,6 +26,8 @@
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 1rem;
--dashboard-mobile-header-height: 4rem;
--dashboard-mobile-header-gap: 0.75rem;
}
:root[data-color-mode="dark"],
@@ -127,9 +129,15 @@
}
@layer utilities {
.dashboard-mobile-header {
min-height: var(--dashboard-mobile-header-height);
}
.dashboard-content-shell {
padding: 1rem;
padding-top: 4rem;
padding-top: calc(
var(--dashboard-mobile-header-height) + var(--dashboard-mobile-header-gap)
);
}
@media (min-width: 768px) {
@@ -143,7 +151,10 @@
padding: 1.25rem;
}
[data-slot="card"] {
[data-slot="card"],
[data-slot="dialog-content"],
[data-slot="alert-dialog-content"],
[data-slot="popover-content"] {
border-radius: var(--radius-lg);
}