diff --git a/eslint.config.js b/eslint.config.js index b497890..e03c85c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import drizzle from "eslint-plugin-drizzle"; export default tseslint.config( { - ignores: [".next"], + ignores: [".next", "scripts/**"], }, ...nextCoreWebVitals, { diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 1f3831b..6e6d3ae 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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 }); diff --git a/src/app/dashboard/_components/active-timer-widget.tsx b/src/app/dashboard/_components/active-timer-widget.tsx index 85a0b47..f00e7ba 100644 --- a/src/app/dashboard/_components/active-timer-widget.tsx +++ b/src/app/dashboard/_components/active-timer-widget.tsx @@ -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 || ( - No description - ); + const description = formatRunningTimerLabel(running.description); const renderStopButton = (className?: string) => ( + } onRowClick={handleRowClick} /> diff --git a/src/app/dashboard/clients/_components/clients-data-table.tsx b/src/app/dashboard/clients/_components/clients-data-table.tsx index 8108597..c2856c2 100644 --- a/src/app/dashboard/clients/_components/clients-data-table.tsx +++ b/src/app/dashboard/clients/_components/clients-data-table.tsx @@ -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={} + emptyAction={ + + } onRowClick={handleRowClick} /> diff --git a/src/app/dashboard/expenses/page.tsx b/src/app/dashboard/expenses/page.tsx index 2cd07a8..6fc7085 100644 --- a/src/app/dashboard/expenses/page.tsx +++ b/src/app/dashboard/expenses/page.tsx @@ -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… ) : expenses.length === 0 ? ( -
- -

- No expenses yet. Add your first expense. -

-
+ } + title="Create your first expense" + description="Track billable costs, reimbursements, and tax-deductible spending." + action={ + + } + /> ) : (
{expenses.map((expense) => ( diff --git a/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx b/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx index a2b685c..1cf7c60 100644 --- a/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx +++ b/src/app/dashboard/invoices/[id]/_components/invoice-timer-card.tsx @@ -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 ( -
- - -
+ ); } diff --git a/src/app/dashboard/invoices/[id]/edit/page.tsx b/src/app/dashboard/invoices/[id]/edit/page.tsx index ecb84a3..f24b0f0 100644 --- a/src/app/dashboard/invoices/[id]/edit/page.tsx +++ b/src/app/dashboard/invoices/[id]/edit/page.tsx @@ -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 ; } diff --git a/src/app/dashboard/invoices/[id]/page.tsx b/src/app/dashboard/invoices/[id]/page.tsx index b8259b0..055f528 100644 --- a/src/app/dashboard/invoices/[id]/page.tsx +++ b/src/app/dashboard/invoices/[id]/page.tsx @@ -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" > - + {storedStatus === "draft" ? ( + + ) : null}
@@ -549,12 +559,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { - + {storedStatus === "draft" ? ( + + ) : null} {invoice.items && invoice.client && ( diff --git a/src/app/dashboard/invoices/_components/invoices-data-table.tsx b/src/app/dashboard/invoices/_components/invoices-data-table.tsx index 9308c54..9792f99 100644 --- a/src/app/dashboard/invoices/_components/invoices-data-table.tsx +++ b/src/app/dashboard/invoices/_components/invoices-data-table.tsx @@ -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) { - + {invoice.status === "draft" ? ( + + + + ) : ( - + )} + } onRowClick={(invoice) => router.push(`/dashboard/invoices/${invoice.id}`) } diff --git a/src/app/dashboard/invoices/page.tsx b/src/app/dashboard/invoices/page.tsx index fb5cff3..74903ac 100644 --- a/src/app/dashboard/invoices/page.tsx +++ b/src/app/dashboard/invoices/page.tsx @@ -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() { Import CSV -
) : (recurring ?? []).length === 0 ? ( - - -

- No recurring invoices yet. Create one to automatically generate draft invoices on a - schedule. -

- + + } + title="Create your first recurring invoice" + description="Automatically generate draft invoices on a schedule you choose." + action={ + + } + />
) : ( diff --git a/src/app/dashboard/onboarding/_components/onboarding-shell.tsx b/src/app/dashboard/onboarding/_components/onboarding-shell.tsx new file mode 100644 index 0000000..9544d77 --- /dev/null +++ b/src/app/dashboard/onboarding/_components/onboarding-shell.tsx @@ -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 ( +
+
+
+
+ +
+

{brand.tagline}

+
+ {children} +
+
+ ); +} diff --git a/src/app/dashboard/onboarding/_components/onboarding-step-indicator.tsx b/src/app/dashboard/onboarding/_components/onboarding-step-indicator.tsx new file mode 100644 index 0000000..1670d46 --- /dev/null +++ b/src/app/dashboard/onboarding/_components/onboarding-step-indicator.tsx @@ -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 ( + + ); +} diff --git a/src/app/dashboard/onboarding/_components/onboarding-wizard.tsx b/src/app/dashboard/onboarding/_components/onboarding-wizard.tsx index e414fc9..ac22fb2 100644 --- a/src/app/dashboard/onboarding/_components/onboarding-wizard.tsx +++ b/src/app/dashboard/onboarding/_components/onboarding-wizard.tsx @@ -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 ( +
+ +
+ ); +} + +function OnboardingPanel({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} 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 ( -
+

Loading…

); } return ( -
-
- -

- {step === "welcome" && "Step 1 of 3"} - {step === "business" && "Step 2 of 3"} - {step === "client" && "Step 3 of 3"} - {step === "done" && "All set"} -

-
+
+ {displayStep !== "done" && } - {step === "welcome" && ( - - - Welcome to BeenVoice - + {displayStep === "welcome" && ( + +
+

+ Quick setup +

+ +

+ Welcome to {brand.name} +

+

Let's set up the basics so you can send your first invoice. This only takes a minute. - - - -

-
- -

Add the business you send invoices from

+

+
+ +
    +
  • +
    +
    -
    - -

    Add your first client to bill

    +
    +

    Add your business

    +

    + The name and details that appear on invoices you send. +

    +
  • +
  • +
    + +
    +
    +

    Add your first client

    +

    + Who you're billing — you can add more details later. +

    +
    +
  • +
+ +
+ + +
+ + )} + + {displayStep === "business" && ( + +
+ +

+ Your business +

+

+ This appears on invoices as the sender — name, logo, and contact + details. +

+
+ +
+
+ + setBusinessName(e.target.value)} + placeholder="Acme Studio LLC" + className="h-11" + autoFocus + />
-
- - +
+
)} - {step === "business" && ( - - - Your business - - This appears on invoices as the sender — name, logo, and contact - details. - - - -
-
- - setBusinessName(e.target.value)} - placeholder="Acme Studio LLC" - autoFocus - /> -
-
- - -
-
-
-
- )} - - {step === "client" && ( - - - Your first client - + {displayStep === "client" && ( + +
+ +

+ Your first client +

+

Who are you billing? You can add more details later. - - - -

-
- - setClientName(e.target.value)} - placeholder="Acme Corp" - autoFocus - /> -
-
- - -
-
- - +

+
+ +
+
+ + setClientName(e.target.value)} + placeholder="Acme Corp" + className="h-11" + autoFocus + /> +
+
+ + +
+
+
)} - {step === "done" && ( - - - - - You're ready to go - - - Your workspace is set up. Create an invoice or explore the - dashboard. - - - - - - - +
+ )} - {step !== "welcome" && step !== "done" && ( - + {step !== "welcome" && displayStep !== "done" && ( +
+ +
)}
); diff --git a/src/app/dashboard/onboarding/page.tsx b/src/app/dashboard/onboarding/page.tsx index b664f27..4f3d5a9 100644 --- a/src/app/dashboard/onboarding/page.tsx +++ b/src/app/dashboard/onboarding/page.tsx @@ -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 ( - + - + ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dc9bad6..b114547 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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" }], }; diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx new file mode 100644 index 0000000..3400211 --- /dev/null +++ b/src/app/opengraph-image.tsx @@ -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( + ( +
+
+
+
+
+ {brand.icon} + + {logoPrefix} + {logoSuffix} +
+
+ Invoicing Made Simple +
+
+ {brand.tagline} +
+
+
+ ), + { + ...size, + fonts: [ + { + name: "Geist Mono", + data: geistMono, + style: "normal", + weight: 700, + }, + { + name: "Playfair Display", + data: playfair, + style: "normal", + weight: 600, + }, + ], + }, + ); +} diff --git a/src/components/branding/logo.tsx b/src/components/branding/logo.tsx index 9728cfe..592f375 100644 --- a/src/components/branding/logo.tsx +++ b/src/components/branding/logo.tsx @@ -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 = { diff --git a/src/components/data/data-table.tsx b/src/components/data/data-table.tsx index 91ae312..ba5613b 100644 --- a/src/components/data/data-table.tsx +++ b/src/components/data/data-table.tsx @@ -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 { 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 ( + + ); } export function DataTable({ @@ -106,6 +143,12 @@ export function DataTable({ onRowClick, selectionActions, initialSorting = [], + emptyTitle, + emptyDescription, + emptyIcon, + emptyAction, + filteredEmptyTitle = "No matches for your search", + filteredEmptyDescription = "Try adjusting your search or filters.", }: DataTableProps) { const [sorting, setSorting] = React.useState(initialSorting); const [columnFilters, setColumnFilters] = React.useState( @@ -190,6 +233,9 @@ export function DataTable({ }, [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({ )) ) : ( - - -

No results found

+ + + {isDatasetEmpty && emptyTitle ? ( + + ) : isFilteredEmpty ? ( + } + title={filteredEmptyTitle} + description={filteredEmptyDescription} + /> + ) : ( +
+ No results found +
+ )}
)} diff --git a/src/components/data/invoice-list.tsx b/src/components/data/invoice-list.tsx index 931aedb..cbe7d8a 100644 --- a/src/components/data/invoice-list.tsx +++ b/src/components/data/invoice-list.tsx @@ -155,11 +155,22 @@ export function InvoiceList() { - - + + ) : ( + - + )} + +
+ + + {previewTab === "pdf" ? ( + + ) : ( +
- - - - +
+ )} +
+ - - -
diff --git a/src/components/forms/invoice-line-items.tsx b/src/components/forms/invoice-line-items.tsx index 9105e5d..b1b9768 100644 --- a/src/components/forms/invoice-line-items.tsx +++ b/src/components/forms/invoice-line-items.tsx @@ -155,7 +155,7 @@ const LineItemCard = React.forwardRef(