+
+ 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.
+
+
+
+
+
)}
- {step === "business" && (
-
-
- Your business
-
- This appears on invoices as the sender — name, logo, and contact
- details.
-
-
-
-
-
-
- )}
-
- {step === "client" && (
-
-
- Your first client
-
+ {displayStep === "client" && (
+
+
+
+
+ Your first client
+
+
Who are you billing? You can add more details later.
-
-
-
-
-
-
+
+
+
+
+
)}
- {step === "done" && (
-
-
-
-
- You're ready to go
-
-
- Your workspace is set up. Create an invoice or explore the
- dashboard.
-
-
-
-
);
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() {
-
-
+ {invoice.status === "draft" ? (
+
+
+
+
+
+ ) : (
+
-
+ )}
+
-
-
+
+
@@ -103,7 +101,7 @@ function plainTextToHtml(value: string) {
.replace(/\n/g, "
");
}
-function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
+function createDefaultInvoiceFormData(): InvoiceFormData {
return {
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#",
@@ -117,30 +115,26 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
taxRate: 0,
currency: "USD",
defaultHourlyRate: null,
- items: blank
- ? []
- : [
- {
- id: crypto.randomUUID(),
- date: new Date(),
- description: "",
- hours: 1,
- rate: 0,
- amount: 0,
- },
- ],
+ items: [
+ {
+ id: crypto.randomUUID(),
+ date: new Date(),
+ description: "",
+ hours: 1,
+ rate: 0,
+ amount: 0,
+ },
+ ],
};
}
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
- const searchParams = useSearchParams();
- const isBlank = searchParams.get("blank") === "1";
const utils = api.useUtils();
// State
const [formData, setFormData] = useState
(() =>
- createDefaultInvoiceFormData(isBlank),
+ createDefaultInvoiceFormData(),
);
const [loading, setLoading] = useState(false);
@@ -148,15 +142,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState("details");
const [previewTab, setPreviewTab] = useState("pdf");
- const [previewPinned, setPreviewPinned] = useState(false);
-
- useEffect(() => {
- const media = window.matchMedia("(min-width: 1024px)");
- const update = () => setPreviewPinned(media.matches);
- update();
- media.addEventListener("change", update);
- return () => media.removeEventListener("change", update);
- }, []);
// Queries (Same as before)
const { data: clients, isLoading: loadingClients } =
@@ -187,7 +172,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}, [invoiceId]);
useEffect(() => {
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
- // ... (Mapping logic same as before)
const mappedItems: InvoiceItem[] =
existingInvoice.items?.map((item) => ({
id: crypto.randomUUID(),
@@ -239,6 +223,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}
}, [invoiceId, existingInvoice, businesses, initialized]);
+ useEffect(() => {
+ if (
+ invoiceId &&
+ invoiceId !== "new" &&
+ existingInvoice &&
+ !loadingInvoice &&
+ existingInvoice.status !== "draft"
+ ) {
+ toast.error("Only draft invoices can be edited");
+ router.replace(`/dashboard/invoices/${invoiceId}`);
+ }
+ }, [invoiceId, existingInvoice, loadingInvoice, router]);
+
const totals = React.useMemo(() => {
const subtotal = formData.items.reduce(
(sum, item) => sum + item.hours * item.rate,
@@ -464,26 +461,20 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
!initialized ||
loadingClients ||
loadingBusinesses ||
- (invoiceId && invoiceId !== "new" && loadingInvoice)
+ (invoiceId && invoiceId !== "new" && loadingInvoice) ||
+ (invoiceId &&
+ invoiceId !== "new" &&
+ existingInvoice &&
+ existingInvoice.status !== "draft")
)
return ;
return (
<>
-
+
{invoiceId !== "new" && (
-
-
+
Details
Items
Timesheet
-
- Preview
-
+ Preview
{/* DETAILS TAB */}
-
+
+
@@ -763,11 +744,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
+
{/* ITEMS TAB */}
-
+
Total
@@ -840,28 +822,48 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
-
-
- PDF
- Email
-
-
-
-
-
-
-
-
-
-
- Email Preview
-
-
-
+
+
+
+
+ Preview
+
+
+ setPreviewTab("pdf")}
+ className={cn(
+ "rounded-md px-3 py-1.5 text-center font-medium transition-all",
+ previewTab === "pdf"
+ ? "bg-background text-foreground shadow"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ PDF
+
+ setPreviewTab("email")}
+ className={cn(
+ "rounded-md px-3 py-1.5 text-center font-medium transition-all",
+ previewTab === "email"
+ ? "bg-background text-foreground shadow"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ Email
+
+
+
+
+ {previewTab === "pdf" ? (
+
+ ) : (
+
-
-
-
-
+
+ )}
+
+
-
-
-