diff --git a/apps/web/src/app/activity/page.tsx b/apps/web/src/app/activity/page.tsx index 187da1c..46e3b44 100644 --- a/apps/web/src/app/activity/page.tsx +++ b/apps/web/src/app/activity/page.tsx @@ -31,6 +31,7 @@ import { } from "@/components/ui/table"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { PageShell } from "@/components/page-shell"; +import { EmptyState } from "@/components/empty-state"; import { getSeededOperator } from "@/lib/operator"; import { listActivityRuns } from "@/lib/queries"; import { @@ -397,25 +398,21 @@ export default async function ActivityPage({ searchParams }: PageProps) { ) : ( - - - -
-

- {filter === "all" - ? "No activity yet." - : showingArchived - ? "No archived runs." - : `No ${filter} runs yet.`} -

-

- {hasAny - ? "Runs in other states aren't shown by this filter." - : "Reminder fire events will appear here."} -

-
-
-
+ )} ); diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 159d1df..b3fdc65 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -39,6 +39,7 @@ import { Badge } from "@/components/ui/badge"; import { getSeededOperator } from "@/lib/operator"; import { getDashboardStats } from "@/lib/queries"; import { PageShell } from "@/components/page-shell"; +import { EmptyState } from "@/components/empty-state"; // --------------------------------------------------------------------------- // Time helpers (no external dep, server-safe) @@ -333,21 +334,17 @@ export default async function DashboardPage() { ) : ( - - - -
-

No reminders have fired yet.

-

- Schedule one to start sending WhatsApp messages. -

-
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} Schedule a reminder -
-
+ } + /> )} diff --git a/apps/web/src/app/reminders/page.tsx b/apps/web/src/app/reminders/page.tsx index 0688c57..373f8fe 100644 --- a/apps/web/src/app/reminders/page.tsx +++ b/apps/web/src/app/reminders/page.tsx @@ -15,6 +15,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { PageShell } from "@/components/page-shell"; +import { EmptyState } from "@/components/empty-state"; import { getSeededOperator } from "@/lib/operator"; import { listAccounts, listReminders } from "@/lib/queries"; import { describeRecurrence, specFromRrule } from "@/lib/recurrence"; @@ -326,26 +327,24 @@ export default async function RemindersPage({ searchParams }: PageProps) { ) : ( - - - -
-

- {allReminders.length === 0 - ? "No reminders yet." - : hasAnyFilter - ? "No reminders match your filters." - : `No ${status} reminders yet.`} -

-

- {allReminders.length === 0 - ? "Create a reminder to start sending scheduled WhatsApp messages." - : hasAnyFilter - ? "Try clearing the filters or widening your search." - : "Reminders in other states aren't shown by this filter."} -

-
- {allReminders.length === 0 && ( + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} @@ -353,9 +352,9 @@ export default async function RemindersPage({ searchParams }: PageProps) { New Reminder - )} -
-
+ ) : undefined + } + /> )} ); diff --git a/apps/web/src/components/accounts-list-view.tsx b/apps/web/src/components/accounts-list-view.tsx index 2342eff..e62d478 100644 --- a/apps/web/src/components/accounts-list-view.tsx +++ b/apps/web/src/components/accounts-list-view.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { PageShell } from "@/components/page-shell"; +import { EmptyState } from "@/components/empty-state"; import { Card, CardContent, @@ -103,24 +104,22 @@ export function AccountsListView({ accounts }: AccountsListViewProps) { ))} ) : ( - - - -
-

No accounts paired yet.

-

- Pair a WhatsApp account to start scheduling reminders. -

-
- -
-
+
+ + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + + Add Account + + + } + /> +
)} ); diff --git a/apps/web/src/components/empty-state.test.tsx b/apps/web/src/components/empty-state.test.tsx new file mode 100644 index 0000000..3987159 --- /dev/null +++ b/apps/web/src/components/empty-state.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ActivityIcon } from "lucide-react"; +import { EmptyState } from "./empty-state"; + +describe("EmptyState", () => { + it("renders the icon, title, and description", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("No activity yet."); + expect(html).toContain("Reminder fire events will appear here."); + // The lucide icon component renders an with the lucide-activity class. + expect(html).toMatch(/]*lucide-activity/); + }); + + it("omits the description when it isn't passed", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("No archived runs."); + // No second

element for the helper text — the only

is the title. + expect((html.match(/ { + const html = renderToStaticMarkup( + Schedule one} + />, + ); + expect(html).toContain('data-testid="cta"'); + expect(html).toContain("Schedule one"); + }); + + it("centres the layout (icon → text → action stack)", () => { + const html = renderToStaticMarkup( + cta} />, + ); + // The CardContent uses flex-col items-center text-center for the + // canonical empty state layout. Lock that in so future tweaks + // can't accidentally drop the centring. + expect(html).toMatch(/flex-col items-center/); + expect(html).toMatch(/text-center/); + }); +}); diff --git a/apps/web/src/components/empty-state.tsx b/apps/web/src/components/empty-state.tsx new file mode 100644 index 0000000..2828152 --- /dev/null +++ b/apps/web/src/components/empty-state.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; + +interface EmptyStateProps { + /** Visual anchor — usually a lucide icon component, rendered at + * size-10 in muted/40 so it reads as decorative rather than active. */ + icon: LucideIcon; + /** One-line headline. Required — empty states without one read as + * "is the page broken?" */ + title: string; + /** Optional explainer below the headline. */ + description?: string; + /** Optional CTA button or action link slot. */ + action?: ReactNode; +} + +/** + * Reusable empty-state card. Tabs render this when their list is + * empty (no accounts paired, no reminders scheduled, no activity + * yet). Centralises the icon / heading / helper / CTA layout so + * every empty surface in the app reads the same. + */ +export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) { + return ( + + +

+

{title}

+ {description && ( +

{description}

+ )} +
+ {action} + + + ); +}