chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
176
components/app-shell.tsx
Normal file
176
components/app-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
components/page-templates.tsx
Normal file
93
components/page-templates.tsx
Normal 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
428
components/placeholders.tsx
Normal 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
109
components/ui.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user