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:
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'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'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'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'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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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" }],
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user