refactor(web): extract EmptyState and reuse on every empty surface

One component now owns the icon / heading / helper / action stack
that the dashboard, accounts list, reminders list, and activity tab
were each rendering inline. The four duplicated 'flex-col items-center
py-12 text-center' Card blocks collapse to one shared surface so the
empty experience reads the same wherever the user lands.

Covered by 4 SSR tests (icon + title + description, omitted helper,
action slot pass-through, centring).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 15:21:51 +08:00
parent a5cb8cea46
commit 3c3a3f57d3
6 changed files with 154 additions and 71 deletions

View File

@ -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) {
</div>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
<ActivityIcon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">
{filter === "all"
<EmptyState
icon={ActivityIcon}
title={
filter === "all"
? "No activity yet."
: showingArchived
? "No archived runs."
: `No ${filter} runs yet.`}
</p>
<p className="text-xs text-muted-foreground">
{hasAny
: `No ${filter} runs yet.`
}
description={
hasAny
? "Runs in other states aren't shown by this filter."
: "Reminder fire events will appear here."}
</p>
</div>
</CardContent>
</Card>
: "Reminder fire events will appear here."
}
/>
)}
</PageShell>
);

View File

@ -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() {
</div>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<ActivityIcon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">No reminders have fired yet.</p>
<p className="text-xs text-muted-foreground">
Schedule one to start sending WhatsApp messages.
</p>
</div>
<EmptyState
icon={ActivityIcon}
title="No reminders have fired yet."
description="Schedule one to start sending WhatsApp messages."
action={
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}>Schedule a reminder</Link>
</Button>
</CardContent>
</Card>
}
/>
)}
</section>
</PageShell>

View File

@ -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) {
</div>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<BellIcon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">
{allReminders.length === 0
<EmptyState
icon={BellIcon}
title={
allReminders.length === 0
? "No reminders yet."
: hasAnyFilter
? "No reminders match your filters."
: `No ${status} reminders yet.`}
</p>
<p className="text-xs text-muted-foreground">
{allReminders.length === 0
: `No ${status} reminders yet.`
}
description={
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."}
</p>
</div>
{allReminders.length === 0 && (
: "Reminders in other states aren't shown by this filter."
}
action={
allReminders.length === 0 ? (
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}>
@ -353,9 +352,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
New Reminder
</Link>
</Button>
)}
</CardContent>
</Card>
) : undefined
}
/>
)}
</PageShell>
);

View File

@ -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,15 +104,12 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
))}
</div>
) : (
<Card data-testid="accounts-empty">
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<SmartphoneIcon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">No accounts paired yet.</p>
<p className="text-xs text-muted-foreground">
Pair a WhatsApp account to start scheduling reminders.
</p>
</div>
<div data-testid="accounts-empty">
<EmptyState
icon={SmartphoneIcon}
title="No accounts paired yet."
description="Pair a WhatsApp account to start scheduling reminders."
action={
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts/new" as any}>
@ -119,8 +117,9 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
Add Account
</Link>
</Button>
</CardContent>
</Card>
}
/>
</div>
)}
</PageShell>
);

View File

@ -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(
<EmptyState
icon={ActivityIcon}
title="No activity yet."
description="Reminder fire events will appear here."
/>,
);
expect(html).toContain("No activity yet.");
expect(html).toContain("Reminder fire events will appear here.");
// The lucide icon component renders an <svg> with the lucide-activity class.
expect(html).toMatch(/<svg[^>]*lucide-activity/);
});
it("omits the description when it isn't passed", () => {
const html = renderToStaticMarkup(
<EmptyState icon={ActivityIcon} title="No archived runs." />,
);
expect(html).toContain("No archived runs.");
// No second <p> element for the helper text — the only <p> is the title.
expect((html.match(/<p\b/g) ?? []).length).toBe(1);
});
it("renders the action slot when provided", () => {
const html = renderToStaticMarkup(
<EmptyState
icon={ActivityIcon}
title="No reminders yet."
action={<button data-testid="cta">Schedule one</button>}
/>,
);
expect(html).toContain('data-testid="cta"');
expect(html).toContain("Schedule one");
});
it("centres the layout (icon → text → action stack)", () => {
const html = renderToStaticMarkup(
<EmptyState icon={ActivityIcon} title="x" action={<span>cta</span>} />,
);
// 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/);
});
});

View File

@ -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 (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<Icon className="size-10 text-muted-foreground/40" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium">{title}</p>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
{action}
</CardContent>
</Card>
);
}