chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled

This commit is contained in:
Wira Basalamah
2026-04-21 09:29:29 +07:00
commit adde003fba
222 changed files with 37657 additions and 0 deletions

176
components/app-shell.tsx Normal file
View File

@ -0,0 +1,176 @@
import Image from "next/image";
import Link from "next/link";
import { ReactNode } from "react";
import { getLocale, getTranslator, type NavKey } from "@/lib/i18n";
import type { NavItem } from "@/lib/mock-data";
type ShellContext = {
userName: string;
roleLabel: string;
tenantName: string;
};
const navIconByKey: Record<NavKey, string> = {
dashboard: "dashboard",
shared_inbox: "inbox",
inbox: "inbox",
contacts: "contacts",
broadcast: "campaign",
templates: "article",
team: "group",
reports: "bar_chart",
settings: "settings",
billing: "payments",
audit_log: "history",
tenants: "domain",
channels: "settings_input_antenna",
security_events: "notifications_active",
webhook_logs: "webhook",
alerts: "warning",
quick_tools: "auto_fix_high",
performance: "trending_up",
new_chat: "chat",
search: "search",
logout: "logout",
global_search: "search",
campaign: "campaign"
};
function initials(name: string) {
return name
.split(" ")
.filter(Boolean)
.map((part) => part[0]?.toUpperCase())
.slice(0, 2)
.join("");
}
function LocaleSwitcher({ locale }: { locale: "id" | "en" }) {
const nextLocale = locale === "id" ? "en" : "id";
return (
<Link
href={`/locale?to=${nextLocale}`}
className="rounded-full border border-line bg-surface-container px-3 py-1 text-xs font-semibold text-on-surface"
>
{nextLocale.toUpperCase()}
</Link>
);
}
export async function AppShell({
title,
subtitle,
nav,
context,
children
}: {
title: string;
subtitle: string;
nav: NavItem[];
context: ShellContext;
children: ReactNode;
}) {
const locale = await getLocale();
const t = getTranslator(locale);
return (
<div className="min-h-screen bg-background text-on-surface">
<div className="mx-auto flex min-h-screen max-w-[1700px]">
<aside className="hidden w-[268px] shrink-0 flex-col space-y-4 border-r border-line bg-surface-container-low px-4 py-6 lg:flex">
<div className="mb-6 rounded-[1.25rem] bg-surface-container-lowest px-3 py-3 shadow-sm">
<div className="flex items-center gap-3 px-2">
<Image
src="/logo_zappcare.png"
alt="ZappCare"
width={36}
height={36}
className="h-9 w-auto rounded-full"
/>
<div>
<p className="text-xs font-extrabold uppercase tracking-[0.24em] text-outline">{t("common", "zappcare")}</p>
<p className="text-lg font-black leading-tight font-headline text-on-surface">{t("common", "business_suite")}</p>
</div>
</div>
</div>
<div className="px-2">
<button className="flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-br from-primary to-primary-container px-4 py-3 text-sm font-bold text-white">
<span className="material-symbols-outlined text-sm">add_comment</span>
<span>{t("nav", "new_chat")}</span>
</button>
</div>
<nav className="space-y-1">
{nav.map((item) => {
const label = t("nav", item.labelKey);
const isDashboard = item.labelKey === "dashboard";
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-semibold font-headline transition-all ${
isDashboard
? "bg-primary-container/40 text-on-primary-container"
: "text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface"
}`}
>
<span
className="material-symbols-outlined text-sm"
style={{ fontVariationSettings: "'FILL' 1", fontSize: "20px" }}
>
{navIconByKey[item.labelKey]}
</span>
<span>{label}</span>
</Link>
);
})}
</nav>
<div className="mt-auto border-t border-line px-2 pt-5">
<Link
href="/auth/logout"
className="flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-semibold text-on-surface-variant transition hover:text-on-surface"
>
<span className="material-symbols-outlined text-sm">logout</span>
<span>{t("nav", "logout")}</span>
</Link>
</div>
</aside>
<div className="flex min-h-screen flex-1 flex-col">
<header className="border-b border-line bg-surface-container-lowest/85 backdrop-blur">
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4 md:px-7">
<div>
<p className="text-xs font-bold uppercase tracking-[0.2em] text-outline">{subtitle}</p>
<h1 className="text-2xl font-black font-headline text-on-surface">{title}</h1>
</div>
<div className="flex items-center gap-3">
<div className="relative hidden w-72 rounded-full border border-line bg-surface-container-low px-4 py-2 md:block">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-sm">search</span>
<input
placeholder={t("common", "search_placeholder")}
className="w-full border-none bg-transparent pl-8 pr-2 text-sm outline-none placeholder:text-outline-variant"
/>
</div>
<LocaleSwitcher locale={locale} />
<button className="flex h-9 w-9 items-center justify-center rounded-full text-outline transition hover:bg-surface-container-low">
<span className="material-symbols-outlined text-sm">notifications</span>
</button>
<button className="flex h-9 w-9 items-center justify-center rounded-full text-outline transition hover:bg-surface-container-low">
<span className="material-symbols-outlined text-sm">help_outline</span>
</button>
<button className="flex h-9 w-9 items-center justify-center rounded-full text-outline transition hover:bg-surface-container-low">
<span className="material-symbols-outlined text-sm">app_shortcut</span>
</button>
<button className="ml-2 flex h-9 items-center justify-center rounded-full border border-line bg-surface-container px-3 text-xs font-bold text-on-surface-variant">
{initials(context.userName)}
</button>
</div>
</div>
<div className="px-7 pb-4 text-sm text-on-surface-variant">
{context.userName} {context.roleLabel} {context.tenantName}
</div>
</header>
<main className="min-h-0 flex-1 px-6 py-6 md:px-8">{children}</main>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,93 @@
import { ReactNode } from "react";
import { AppShell } from "@/components/app-shell";
import { Button, PageHeader } from "@/components/ui";
import { getLocale, getTranslator } from "@/lib/i18n";
import { getSession } from "@/lib/auth";
import { adminNav, agentNav, superAdminNav } from "@/lib/mock-data";
type ShellType = "admin" | "agent" | "super-admin";
const shellMap = {
admin: {
nav: adminNav,
titleKey: "admin_title" as const,
subtitleKey: "admin_subtitle" as const
},
agent: {
nav: agentNav,
titleKey: "agent_title" as const,
subtitleKey: "agent_subtitle" as const
},
"super-admin": {
nav: superAdminNav,
titleKey: "super_admin_title" as const,
subtitleKey: "super_admin_subtitle" as const
}
} as const;
export async function ShellPage({
shell,
title,
description,
actions,
children
}: {
shell: ShellType;
title: string;
description: string;
actions?: ReactNode;
children: ReactNode;
}) {
const locale = await getLocale();
const t = getTranslator(locale);
const config = shellMap[shell];
const session = await getSession();
const roleLabel =
session?.role === "super_admin" ? t("roles", "super_admin") : session?.role === "agent" ? t("roles", "agent") : t("roles", "admin_client");
const tenantName = session?.tenantName ?? "Inbox Suite";
const shellTitle = t("shell", config.titleKey);
const shellSubtitle = t("shell", config.subtitleKey);
return (
<AppShell
title={shellTitle}
subtitle={shellSubtitle}
nav={config.nav}
context={{
userName: session?.fullName ?? "Guest User",
roleLabel,
tenantName
}}
>
<div className="space-y-6 pb-8">
<PageHeader title={title} description={description} actions={actions} />
{children}
</div>
</AppShell>
);
}
export function PlaceholderActions({
primaryHref,
primaryLabel,
secondaryHref,
secondaryLabel
}: {
primaryHref?: string;
primaryLabel?: string;
secondaryHref?: string;
secondaryLabel?: string;
}) {
return (
<>
{secondaryHref && secondaryLabel ? (
<Button href={secondaryHref} variant="secondary">
{secondaryLabel}
</Button>
) : null}
{primaryHref && primaryLabel ? <Button href={primaryHref}>{primaryLabel}</Button> : null}
</>
);
}

428
components/placeholders.tsx Normal file
View File

@ -0,0 +1,428 @@
import Link from "next/link";
import { ReactNode } from "react";
import { Badge, SectionCard } from "@/components/ui";
import { getLocale, getTranslator } from "@/lib/i18n";
import type { CampaignRecord, ContactRecord, ConversationRecord, DashboardStats, TenantRecord, UserRecord } from "@/lib/demo-data";
import type {
ConversationMessage,
ConversationNote,
ConversationSummary,
InboxConversationDetail
} from "@/lib/inbox-ops";
export async function DashboardPlaceholder({
stats,
priorityQueue
}: {
stats: DashboardStats[];
priorityQueue: Array<ConversationRecord | CampaignRecord | TenantRecord>;
}) {
const t = getTranslator(await getLocale());
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{stats.map((item) => (
<SectionCard key={item.label} title={item.label}>
<div className="flex items-end justify-between">
<span className="text-3xl font-extrabold text-on-surface">{item.value}</span>
<Badge tone="success">{item.delta}</Badge>
</div>
</SectionCard>
))}
</div>
<div className="grid gap-6 xl:grid-cols-[1.35fr_1fr]">
<SectionCard title={t("placeholders", "operational_overview_title")} description={t("placeholders", "operational_overview_desc")}>
<div className="h-72 rounded-[1.25rem] border border-dashed border-line bg-gradient-to-br from-primary/5 to-surface-container-high p-6 text-sm text-on-surface-variant">
{t("placeholders", "operation_chart_note")}
</div>
</SectionCard>
<SectionCard title={t("placeholders", "priority_queue_title")} description={t("placeholders", "priority_queue_desc")}>
<div className="space-y-3">
{priorityQueue.map((item) => (
<div key={item.id} className="rounded-xl border border-line bg-surface-container-low p-4">
<div className="flex items-center justify-between gap-3">
<p className="font-medium text-ink">{item.name}</p>
<Badge tone={item.status === "Completed" || item.status === "Resolved" || item.status === "Active" ? "success" : "warning"}>
{item.status}
</Badge>
</div>
<p className="mt-2 text-sm text-on-surface-variant">
{"snippet" in item
? item.snippet
: "plan" in item
? `${item.plan}${item.channels}`
: `${item.audience}${item.channel}`}
</p>
<p className="mt-3 text-xs text-outline">
{"time" in item ? `${item.time}${item.assignee}` : "seats" in item ? item.seats : item.scheduledAt}
</p>
</div>
))}
</div>
</SectionCard>
</div>
</div>
);
}
export async function InboxPlaceholder({
conversations,
selectedConversation,
defaultPath,
agents = [],
filter = "all",
role = "admin",
canSelfAssign = false,
assignConversation,
updateConversationStatus,
replyToConversation,
addConversationNote,
setConversationTags
}: {
conversations: ConversationSummary[];
selectedConversation: InboxConversationDetail | null;
defaultPath: string;
agents?: Array<{ id: string; name: string }>;
filter?: "all" | "unassigned" | "resolved" | "open" | "pending";
role?: "admin" | "agent";
canSelfAssign?: boolean;
assignConversation?: (formData: FormData) => Promise<void>;
updateConversationStatus?: (formData: FormData) => Promise<void>;
replyToConversation?: (formData: FormData) => Promise<void>;
addConversationNote?: (formData: FormData) => Promise<void>;
setConversationTags?: (formData: FormData) => Promise<void>;
}) {
const t = getTranslator(await getLocale());
const currentPath = defaultPath || "/inbox";
const selectedId = selectedConversation?.id;
const statusTone = (status: string) => {
if (status === "Resolved") {
return "success";
}
if (status === "Pending") {
return "warning";
}
return "default";
};
const statusValue = (status: string) => {
switch (status) {
case "Open":
return "OPEN";
case "Pending":
return "PENDING";
case "Resolved":
return "RESOLVED";
default:
return "OPEN";
}
};
const tagsValue = selectedConversation?.tagJson
? JSON.parse(selectedConversation.tagJson).filter((tag: string) => Boolean(tag.trim())).join(", ")
: "";
return (
<div className="grid gap-4 xl:grid-cols-[360px_1fr_320px]">
<SectionCard title={t("placeholders", "conversation_list_title")} description={t("placeholders", "conversation_list_desc")}>
<div className="space-y-3">
{conversations.map((item) => (
<Link
key={item.id}
href={`${currentPath}?conversationId=${item.id}&filter=${filter}`}
className={`block rounded-xl border p-4 transition ${
item.id === selectedId
? "border-primary bg-primary/5"
: "border-line hover:border-primary/50 hover:bg-surface-container-low"
}`}
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-ink">{item.name}</p>
<span className="text-xs text-outline">{item.time}</span>
</div>
<p className="mt-2 text-sm text-on-surface-variant">{item.snippet}</p>
<div className="mt-3 flex items-center justify-between text-xs text-outline">
<span>{item.assignee}</span>
<Badge tone={statusTone(item.status)}>{item.status}</Badge>
</div>
</Link>
))}
{!conversations.length ? <p className="text-sm text-outline">{t("placeholders", "no_conversation_found")}</p> : null}
</div>
</SectionCard>
<SectionCard title={t("placeholders", "conversation_detail_title")} description={t("placeholders", "conversation_detail_desc")}>
{!selectedConversation ? (
<p className="text-sm text-on-surface-variant">{t("placeholders", "select_to_reply")}</p>
) : (
<div className="space-y-4">
<div className="rounded-xl border border-line bg-surface-container p-4 text-sm text-on-surface-variant">
<p className="font-medium text-ink">{selectedConversation.name} {selectedConversation.phone}</p>
<p>{selectedConversation.channel}</p>
<p>{selectedConversation.assignee}</p>
<p className="mt-2 text-outline">
{localeStatus(t, "status", selectedConversation.status)}{" "}
<Badge tone={statusTone(selectedConversation.status)}>{selectedConversation.status}</Badge>
</p>
</div>
<div className="rounded-2xl border border-line bg-surface-container p-4 text-sm text-on-surface-variant">
{selectedConversation.messages.length === 0 ? t("placeholders", "no_messages") : null}
<div className="space-y-2">
{selectedConversation.messages.map((message: ConversationMessage) => (
<MessageBubble
key={message.id}
align={message.direction === "INBOUND" ? "left" : "right"}
meta={`${message.from}${message.at}`}
>
{message.body}
</MessageBubble>
))}
</div>
</div>
<form action={replyToConversation} className="rounded-2xl border border-line bg-surface-container p-4">
<p className="mb-2 text-sm font-semibold text-on-surface">{t("placeholders", "reply_label")}</p>
<input type="hidden" name="conversationId" value={selectedConversation.id} />
<input type="hidden" name="nextPath" value={currentPath} />
<textarea
name="content"
required
rows={3}
placeholder={t("placeholders", "reply_placeholder")}
className="h-auto min-h-[110px] w-full rounded-lg border border-line bg-surface-container-low px-3 py-2 text-sm outline-none"
/>
<button className="mt-2 rounded-full bg-primary px-4 py-2 text-xs text-white" type="submit">
{t("placeholders", "send_reply")}
</button>
</form>
</div>
)}
</SectionCard>
<SectionCard title={t("placeholders", "context_panel_title")} description={t("placeholders", "context_panel_desc")}>
{!selectedConversation ? (
<p className="text-sm text-on-surface-variant">{t("placeholders", "select_context")}</p>
) : (
<div className="space-y-3 text-sm text-on-surface-variant">
<ul className="space-y-2">
<li className="rounded-xl bg-surface-container p-3">
<p className="font-medium text-ink">{t("placeholders", "contact_label")}</p>
<p>{selectedConversation.name}</p>
<p>{selectedConversation.phone}</p>
</li>
<li className="rounded-xl bg-surface-container p-3">
<p className="font-medium text-ink">{t("placeholders", "tags_label")}</p>
<p>{tagsValue || "-"}</p>
</li>
</ul>
<form action={assignConversation} className="rounded-xl bg-surface-container p-3 space-y-2">
<p className="font-semibold text-ink">{t("placeholders", "assign_label")}</p>
<input type="hidden" name="conversationId" value={selectedConversation.id} />
<input type="hidden" name="nextPath" value={currentPath} />
{role === "admin" ? (
<select name="assigneeId" className="w-full rounded border border-line bg-surface-container-low px-3 py-2">
<option value="">{t("placeholders", "unassign_label")}</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
) : null}
{role === "agent" && canSelfAssign ? <input type="hidden" name="assigneeId" value={""} /> : null}
<button className="rounded-full bg-surface-container-lowest px-4 py-2 text-xs text-on-surface" type="submit">
{role === "admin"
? t("placeholders", "assign_label")
: selectedConversation.assignee === "Unassigned"
? t("placeholders", "take_assignment")
: t("placeholders", "reassign")}
</button>
</form>
<form action={updateConversationStatus} className="rounded-xl bg-surface-container p-3 space-y-2">
<p className="font-semibold text-ink">{t("placeholders", "status_label")}</p>
<input type="hidden" name="conversationId" value={selectedConversation.id} />
<input type="hidden" name="nextPath" value={currentPath} />
<select name="status" defaultValue={statusValue(selectedConversation.status)} className="w-full rounded border border-line bg-surface-container-low px-3 py-2">
<option value="OPEN">{t("placeholders", "status_open")}</option>
<option value="PENDING">{t("placeholders", "status_pending")}</option>
<option value="RESOLVED">{t("placeholders", "status_resolved")}</option>
<option value="ARCHIVED">{t("placeholders", "status_archived")}</option>
<option value="SPAM">{t("placeholders", "status_spam")}</option>
</select>
<button className="rounded-full bg-surface-container-lowest px-4 py-2 text-xs text-on-surface" type="submit">
{t("placeholders", "update_status")}
</button>
</form>
<form action={addConversationNote} className="rounded-xl bg-surface-container p-3 space-y-2">
<p className="font-semibold text-ink">{t("placeholders", "add_note_label")}</p>
<input type="hidden" name="conversationId" value={selectedConversation.id} />
<input type="hidden" name="nextPath" value={currentPath} />
<input
name="note"
required
placeholder={t("placeholders", "add_note_placeholder")}
className="w-full rounded border border-line bg-surface-container-low px-3 py-2"
/>
<button className="rounded-full bg-surface-container-lowest px-4 py-2 text-xs text-on-surface" type="submit">
{t("placeholders", "save_note")}
</button>
</form>
<form action={setConversationTags} className="rounded-xl bg-surface-container p-3 space-y-2">
<p className="font-semibold text-ink">{t("placeholders", "tags_label")}</p>
<input type="hidden" name="conversationId" value={selectedConversation.id} />
<input type="hidden" name="nextPath" value={currentPath} />
<input
name="tags"
defaultValue={tagsValue}
placeholder={t("placeholders", "tags_placeholder")}
className="w-full rounded border border-line bg-surface-container-low px-3 py-2"
/>
<button className="rounded-full bg-surface-container-lowest px-4 py-2 text-xs text-on-surface" type="submit">
{t("placeholders", "save_tags")}
</button>
</form>
<div className="rounded-xl bg-surface-container p-3">
<p className="font-semibold text-ink">{t("placeholders", "notes_label")}</p>
{selectedConversation.notes.length === 0 ? <p className="text-xs text-outline">{t("placeholders", "no_notes")}</p> : null}
<ul className="mt-2 space-y-2">
{selectedConversation.notes.map((item: ConversationNote) => (
<li key={item.id} className="rounded bg-surface-container-low p-2">
<p className="text-xs text-outline">
{item.by} {item.at}
</p>
<p className="text-sm text-on-surface">{item.content}</p>
</li>
))}
</ul>
</div>
</div>
)}
</SectionCard>
</div>
);
}
function MessageBubble({
align,
children,
meta
}: {
align: "left" | "right";
children: ReactNode;
meta?: string;
}) {
const className =
align === "left"
? "mr-auto max-w-xl rounded-2xl rounded-bl-md bg-surface-container-low"
: "ml-auto max-w-xl rounded-2xl rounded-br-md bg-primary text-white";
return (
<div className={`border border-line px-4 py-3 text-sm shadow-sm ${className}`}>
{children}
{meta ? <p className="mt-2 text-xs opacity-70">{meta}</p> : null}
</div>
);
}
export function TablePlaceholder({
columns,
rows,
title,
description
}: {
title: string;
description?: string;
columns: string[];
rows: Array<(string | ReactNode)[]>;
}) {
return (
<SectionCard title={title} description={description}>
<div className="overflow-hidden rounded-[1.25rem] border border-line">
<table className="min-w-full divide-y divide-line text-left text-sm">
<thead className="bg-surface-container">
<tr>
{columns.map((column) => (
<th key={column} className="px-4 py-3 font-semibold text-on-surface-variant">
{column}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line bg-surface-container-lowest">
{rows.map((row, rowIndex) => (
<tr key={`${title}-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`${rowIndex}-${cellIndex}`} className="px-4 py-3 text-on-surface-variant">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</SectionCard>
);
}
export async function ContactSummaryCards({ contacts }: { contacts: ContactRecord[] }) {
const t = getTranslator(await getLocale());
return (
<div className="grid gap-4 md:grid-cols-3">
<SectionCard title={t("tables", "total_contacts")}>
<p className="text-3xl font-semibold text-ink">{contacts.length}</p>
</SectionCard>
<SectionCard title={t("tables", "opted_in")}>
<p className="text-3xl font-semibold text-ink">
{contacts.filter((contact) => contact.optInStatus === "Opted in").length}
</p>
</SectionCard>
<SectionCard title={t("tables", "tagged_contacts")}>
<p className="text-3xl font-semibold text-ink">
{contacts.filter((contact) => contact.tags.length > 0).length}
</p>
</SectionCard>
</div>
);
}
export async function TeamSummaryCards({ users }: { users: UserRecord[] }) {
const t = getTranslator(await getLocale());
return (
<div className="grid gap-4 md:grid-cols-3">
<SectionCard title={t("tables", "total_users")}>
<p className="text-3xl font-semibold text-ink">{users.length}</p>
</SectionCard>
<SectionCard title={t("tables", "agents")}>
<p className="text-3xl font-semibold text-ink">{users.filter((user) => user.role === "Agent").length}</p>
</SectionCard>
<SectionCard title={t("tables", "invited")}>
<p className="text-3xl font-semibold text-ink">{users.filter((user) => user.status === "Invited").length}</p>
</SectionCard>
</div>
);
}
function localeStatus(t: ReturnType<typeof getTranslator>, section: "status", key: string) {
if (key === "Open") {
return t("placeholders", "status_open");
}
if (key === "Pending") {
return t("placeholders", "status_pending");
}
if (key === "Resolved") {
return t("placeholders", "status_resolved");
}
return key;
}

109
components/ui.tsx Normal file
View File

@ -0,0 +1,109 @@
import Link from "next/link";
import { ReactNode } from "react";
export function Button({
href,
children,
variant = "primary",
className,
type = "button"
}: {
href?: string;
children: ReactNode;
variant?: "primary" | "secondary" | "ghost";
className?: string;
type?: "button" | "submit" | "reset";
}) {
const styleClass =
variant === "primary"
? "bg-gradient-to-br from-primary to-primary-container text-white rounded-full hover:brightness-105 shadow-sm shadow-primary/30"
: variant === "secondary"
? "bg-surface-container-low text-on-surface border border-outline-variant/70 rounded-full hover:bg-surface-container-high"
: "text-on-surface-variant hover:text-on-surface hover:bg-surface-container-low";
if (href) {
return (
<Link
href={href}
className={`inline-flex items-center justify-center rounded-full px-4 py-2.5 text-sm font-semibold font-headline transition ${styleClass} ${
className ?? ""
} ${className?.includes("w-full") ? "" : "min-w-24"}`}
>
{children}
</Link>
);
}
return (
<button
type={type}
className={`inline-flex items-center justify-center rounded-full px-4 py-2.5 text-sm font-semibold font-headline transition ${styleClass} ${
className ?? ""
} ${className?.includes("w-full") ? "" : "min-w-24"}`}
>
{children}
</button>
);
}
export function SectionCard({
title,
description,
children
}: {
title: string;
description?: string;
children: ReactNode;
}) {
return (
<section className="rounded-[1.25rem] border border-line bg-surface-container-lowest p-5 shadow-card">
<div className="mb-4">
<h3 className="text-base font-bold font-headline text-on-surface">{title}</h3>
{description ? <p className="mt-1 text-sm text-on-surface-variant">{description}</p> : null}
</div>
{children}
</section>
);
}
export function Badge({
children,
tone = "default"
}: {
children: ReactNode;
tone?: "default" | "success" | "warning" | "danger";
}) {
const tones = {
default: "bg-surface-container-high text-on-surface-variant",
success: "bg-success/10 text-success",
warning: "bg-warning/10 text-warning",
danger: "bg-error/10 text-danger"
};
return (
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${tones[tone]}`}>
{children}
</span>
);
}
export function PageHeader({
title,
description,
actions
}: {
title: string;
description: string;
actions?: ReactNode;
}) {
return (
<div className="flex flex-col gap-4 border-b border-line pb-6 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-[0.16em] text-on-surface-variant">ZappCare</p>
<h1 className="mt-2 text-3xl font-extrabold font-headline text-on-surface">{title}</h1>
<p className="mt-2 max-w-2xl text-sm text-on-surface-variant">{description}</p>
</div>
{actions ? <div className="flex flex-wrap gap-3">{actions}</div> : null}
</div>
);
}