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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user