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"; } from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageShell } from "@/components/page-shell"; import { PageShell } from "@/components/page-shell";
import { EmptyState } from "@/components/empty-state";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { listActivityRuns } from "@/lib/queries"; import { listActivityRuns } from "@/lib/queries";
import { import {
@ -397,25 +398,21 @@ export default async function ActivityPage({ searchParams }: PageProps) {
</div> </div>
</> </>
) : ( ) : (
<Card> <EmptyState
<CardContent className="flex flex-col items-center gap-3 py-12 text-center"> icon={ActivityIcon}
<ActivityIcon className="size-10 text-muted-foreground/40" /> title={
<div className="space-y-1"> filter === "all"
<p className="text-sm font-medium"> ? "No activity yet."
{filter === "all" : showingArchived
? "No activity yet." ? "No archived runs."
: showingArchived : `No ${filter} runs yet.`
? "No archived runs." }
: `No ${filter} runs yet.`} description={
</p> hasAny
<p className="text-xs text-muted-foreground"> ? "Runs in other states aren't shown by this filter."
{hasAny : "Reminder fire events will appear here."
? "Runs in other states aren't shown by this filter." }
: "Reminder fire events will appear here."} />
</p>
</div>
</CardContent>
</Card>
)} )}
</PageShell> </PageShell>
); );

View File

@ -39,6 +39,7 @@ import { Badge } from "@/components/ui/badge";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getDashboardStats } from "@/lib/queries"; import { getDashboardStats } from "@/lib/queries";
import { PageShell } from "@/components/page-shell"; import { PageShell } from "@/components/page-shell";
import { EmptyState } from "@/components/empty-state";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Time helpers (no external dep, server-safe) // Time helpers (no external dep, server-safe)
@ -333,21 +334,17 @@ export default async function DashboardPage() {
</div> </div>
</> </>
) : ( ) : (
<Card> <EmptyState
<CardContent className="flex flex-col items-center gap-4 py-12 text-center"> icon={ActivityIcon}
<ActivityIcon className="size-10 text-muted-foreground/40" /> title="No reminders have fired yet."
<div className="space-y-1"> description="Schedule one to start sending WhatsApp messages."
<p className="text-sm font-medium">No reminders have fired yet.</p> action={
<p className="text-xs text-muted-foreground">
Schedule one to start sending WhatsApp messages.
</p>
</div>
<Button asChild size="sm"> <Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}>Schedule a reminder</Link> <Link href={"/reminders/new" as any}>Schedule a reminder</Link>
</Button> </Button>
</CardContent> }
</Card> />
)} )}
</section> </section>
</PageShell> </PageShell>

View File

@ -15,6 +15,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageShell } from "@/components/page-shell"; import { PageShell } from "@/components/page-shell";
import { EmptyState } from "@/components/empty-state";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { listAccounts, listReminders } from "@/lib/queries"; import { listAccounts, listReminders } from "@/lib/queries";
import { describeRecurrence, specFromRrule } from "@/lib/recurrence"; import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
@ -326,26 +327,24 @@ export default async function RemindersPage({ searchParams }: PageProps) {
</div> </div>
</> </>
) : ( ) : (
<Card> <EmptyState
<CardContent className="flex flex-col items-center gap-4 py-12 text-center"> icon={BellIcon}
<BellIcon className="size-10 text-muted-foreground/40" /> title={
<div className="space-y-1"> allReminders.length === 0
<p className="text-sm font-medium"> ? "No reminders yet."
{allReminders.length === 0 : hasAnyFilter
? "No reminders yet." ? "No reminders match your filters."
: hasAnyFilter : `No ${status} reminders yet.`
? "No reminders match your filters." }
: `No ${status} reminders yet.`} description={
</p> allReminders.length === 0
<p className="text-xs text-muted-foreground"> ? "Create a reminder to start sending scheduled WhatsApp messages."
{allReminders.length === 0 : hasAnyFilter
? "Create a reminder to start sending scheduled WhatsApp messages." ? "Try clearing the filters or widening your search."
: hasAnyFilter : "Reminders in other states aren't shown by this filter."
? "Try clearing the filters or widening your search." }
: "Reminders in other states aren't shown by this filter."} action={
</p> allReminders.length === 0 ? (
</div>
{allReminders.length === 0 && (
<Button asChild size="sm"> <Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}> <Link href={"/reminders/new" as any}>
@ -353,9 +352,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
New Reminder New Reminder
</Link> </Link>
</Button> </Button>
)} ) : undefined
</CardContent> }
</Card> />
)} )}
</PageShell> </PageShell>
); );

View File

@ -2,6 +2,7 @@ import Link from "next/link";
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react"; import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageShell } from "@/components/page-shell"; import { PageShell } from "@/components/page-shell";
import { EmptyState } from "@/components/empty-state";
import { import {
Card, Card,
CardContent, CardContent,
@ -103,24 +104,22 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
))} ))}
</div> </div>
) : ( ) : (
<Card data-testid="accounts-empty"> <div data-testid="accounts-empty">
<CardContent className="flex flex-col items-center gap-4 py-12 text-center"> <EmptyState
<SmartphoneIcon className="size-10 text-muted-foreground/40" /> icon={SmartphoneIcon}
<div className="space-y-1"> title="No accounts paired yet."
<p className="text-sm font-medium">No accounts paired yet.</p> description="Pair a WhatsApp account to start scheduling reminders."
<p className="text-xs text-muted-foreground"> action={
Pair a WhatsApp account to start scheduling reminders. <Button asChild size="sm">
</p> {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
</div> <Link href={"/accounts/new" as any}>
<Button asChild size="sm"> <PlusIcon />
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} Add Account
<Link href={"/accounts/new" as any}> </Link>
<PlusIcon /> </Button>
Add Account }
</Link> />
</Button> </div>
</CardContent>
</Card>
)} )}
</PageShell> </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>
);
}