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,
},
],
},
);
}