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

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;
}