mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Comms app offsite presentation
This commit is contained in:
parent
38237fe363
commit
52fe2d2207
@ -1,16 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"afterFileEdit": [
|
||||
{
|
||||
"command": "./hooks/lint-file.sh"
|
||||
}
|
||||
],
|
||||
"stop": [
|
||||
{
|
||||
"command": "./hooks/stop-check.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter } from "@/components/router";
|
||||
import { isAppEnabled } from "@/lib/apps-utils";
|
||||
import { AppId } from "@hexclave/shared/dist/apps/apps-config";
|
||||
import type { AppId } from "@/lib/shared-apps-config";
|
||||
import { Typography } from "@/components/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@ -0,0 +1,546 @@
|
||||
"use client";
|
||||
|
||||
import { Link } from "@/components/link";
|
||||
import {
|
||||
DesignBadge,
|
||||
DesignButton,
|
||||
DesignCard,
|
||||
DesignInput,
|
||||
DesignMenu,
|
||||
DesignSelectorDropdown,
|
||||
} from "@/components/design-components";
|
||||
import { Avatar, AvatarFallback, AvatarImage, Textarea, Typography, cn } from "@/components/ui";
|
||||
import {
|
||||
COMMS_CONTACTS,
|
||||
COMMS_PLATFORM_LABELS,
|
||||
COMMS_PLATFORM_OPTIONS,
|
||||
type CommsContact,
|
||||
type CommsDraft,
|
||||
type CommsMessage,
|
||||
type CommsPlatform,
|
||||
formatCommsTimestamp,
|
||||
getCommsContact,
|
||||
getCommsTopic,
|
||||
} from "@/lib/comms-mock";
|
||||
import {
|
||||
BellRingingIcon,
|
||||
ChatCircleDotsIcon,
|
||||
DiscordLogoIcon,
|
||||
EnvelopeSimpleIcon,
|
||||
GitMergeIcon,
|
||||
PaperPlaneTiltIcon,
|
||||
SlackLogoIcon,
|
||||
TicketIcon,
|
||||
UserCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type ComposerValue = {
|
||||
platform: CommsPlatform,
|
||||
contactId: string,
|
||||
recipients: string,
|
||||
channelLabel: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
attachments: string,
|
||||
};
|
||||
|
||||
const platformIconMap = new Map<CommsPlatform, typeof EnvelopeSimpleIcon>([
|
||||
["email", EnvelopeSimpleIcon],
|
||||
["slack", SlackLogoIcon],
|
||||
["discord", DiscordLogoIcon],
|
||||
["support", TicketIcon],
|
||||
["push", BellRingingIcon],
|
||||
]);
|
||||
|
||||
const platformColorMap = new Map<CommsPlatform, "blue" | "green" | "purple" | "orange" | "cyan">([
|
||||
["email", "blue"],
|
||||
["slack", "green"],
|
||||
["discord", "purple"],
|
||||
["support", "orange"],
|
||||
["push", "cyan"],
|
||||
]);
|
||||
|
||||
function getPlatformIcon(platform: CommsPlatform) {
|
||||
const Icon = platformIconMap.get(platform);
|
||||
if (!Icon) {
|
||||
throw new Error(`Missing platform icon for ${platform}`);
|
||||
}
|
||||
return Icon;
|
||||
}
|
||||
|
||||
function getPlatformColor(platform: CommsPlatform) {
|
||||
const color = platformColorMap.get(platform);
|
||||
if (!color) {
|
||||
throw new Error(`Missing platform color for ${platform}`);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
export function PlatformBadge({ platform }: { platform: CommsPlatform }) {
|
||||
const Icon = getPlatformIcon(platform);
|
||||
return (
|
||||
<DesignBadge label={COMMS_PLATFORM_LABELS[platform]} icon={Icon} color={getPlatformColor(platform)} size="sm" />
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactAvatar({ contact, size = "md" }: { contact: CommsContact, size?: "sm" | "md" | "lg" }) {
|
||||
const sizeClass = size === "lg" ? "h-16 w-16" : size === "sm" ? "h-8 w-8" : "h-10 w-10";
|
||||
return (
|
||||
<Avatar className={cn(sizeClass, "shrink-0")}>
|
||||
<AvatarImage src={contact.avatarUrl} alt={contact.name} />
|
||||
<AvatarFallback>{contact.name.slice(0, 2)}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactIdentityLine({ contact }: { contact: CommsContact }) {
|
||||
const primaryEmail = contact.channels.emails[0];
|
||||
const primaryDiscord = contact.channels.discordIds[0];
|
||||
const primarySlack = contact.channels.slackIds[0];
|
||||
const primary = primaryEmail ?? primarySlack ?? primaryDiscord ?? contact.source;
|
||||
return (
|
||||
<Typography variant="secondary" className="truncate text-xs">
|
||||
{contact.company} · {contact.role} · {primary}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactMiniCard({ contact, projectId }: { contact: CommsContact, projectId: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${encodeURIComponent(projectId)}/contacts/${encodeURIComponent(contact.id)}`}
|
||||
className="block rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<DesignCard glassmorphic contentClassName="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<ContactAvatar contact={contact} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Typography className="truncate text-sm font-semibold">{contact.name}</Typography>
|
||||
<DesignBadge label={contact.lifecycle} color={contact.lifecycle === "At risk" ? "orange" : "blue"} size="sm" />
|
||||
</div>
|
||||
<ContactIdentityLine contact={contact} />
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{contact.tags.slice(0, 3).map((tag) => (
|
||||
<DesignBadge key={tag} label={tag} color="cyan" size="sm" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DesignCard>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageRow({
|
||||
message,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
message: CommsMessage,
|
||||
selected: boolean,
|
||||
onSelect: () => void,
|
||||
}) {
|
||||
const contact = getCommsContact(message.contactId);
|
||||
const topic = getCommsTopic(message.topicId);
|
||||
if (contact == null || topic == null) {
|
||||
throw new Error(`Mock message ${message.id} references a missing contact or topic.`);
|
||||
}
|
||||
const Icon = getPlatformIcon(message.platform);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"w-full rounded-2xl p-4 text-left transition-colors duration-150 hover:transition-none",
|
||||
"border border-border/60 bg-background/70 hover:bg-foreground/[0.04]",
|
||||
selected && "border-blue-500/40 bg-blue-500/[0.06]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative">
|
||||
<ContactAvatar contact={contact} size="sm" />
|
||||
<div className="absolute -bottom-1 -right-1 rounded-full bg-background p-0.5 ring-1 ring-border">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Typography className="truncate text-sm font-semibold">{contact.name}</Typography>
|
||||
<Typography variant="secondary" className="shrink-0 text-[11px]">
|
||||
{formatCommsTimestamp(message.timestamp)}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<PlatformBadge platform={message.platform} />
|
||||
<DesignBadge label={message.direction === "inbound" ? "Inbound" : "Sent"} color={message.direction === "inbound" ? "green" : "blue"} size="sm" />
|
||||
{message.urgency === "high" ? <DesignBadge label="High urgency" color="orange" size="sm" /> : null}
|
||||
</div>
|
||||
<Typography className="mt-2 truncate text-sm font-medium">
|
||||
{message.subject ?? topic.title}
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="mt-1 line-clamp-2 text-xs">
|
||||
{message.text}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageDetailPanel({ message, projectId }: { message: CommsMessage, projectId: string }) {
|
||||
const contact = getCommsContact(message.contactId);
|
||||
const topic = getCommsTopic(message.topicId);
|
||||
if (contact == null || topic == null) {
|
||||
throw new Error(`Mock message ${message.id} references a missing contact or topic.`);
|
||||
}
|
||||
return (
|
||||
<DesignCard glassmorphic contentClassName="p-0 overflow-hidden">
|
||||
<div className="border-b border-border/60 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<PlatformBadge platform={message.platform} />
|
||||
<DesignBadge label={message.status} color={message.status === "new" ? "green" : "blue"} size="sm" />
|
||||
</div>
|
||||
<Typography className="text-lg font-semibold">
|
||||
{message.subject ?? topic.title}
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="mt-1 text-sm">
|
||||
{message.direction === "inbound" ? "Received" : "Sent"} via {message.channelLabel} · {formatCommsTimestamp(message.timestamp)}
|
||||
</Typography>
|
||||
</div>
|
||||
<DesignMenu
|
||||
variant="actions"
|
||||
trigger="button"
|
||||
triggerLabel="Actions"
|
||||
align="end"
|
||||
items={[
|
||||
{ id: "assign", label: "Assign to me" },
|
||||
{ id: "topic", label: "Move to another topic" },
|
||||
{ id: "split", label: "Split into new topic" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-0 lg:grid-cols-[1fr_280px]">
|
||||
<div className="space-y-5 p-5">
|
||||
{message.platform === "email" && message.htmlBody != null ? (
|
||||
<div className="rounded-2xl border border-border/60 bg-foreground/[0.02] p-4">
|
||||
<Typography className="text-sm leading-6">{message.text}</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border/60 bg-foreground/[0.02] p-4">
|
||||
<Typography className="whitespace-pre-wrap text-sm leading-6">{message.text}</Typography>
|
||||
</div>
|
||||
)}
|
||||
{message.attachments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Attachments</Typography>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.attachments.map((attachment) => (
|
||||
<DesignBadge key={attachment} label={attachment} color="cyan" size="sm" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DesignCard glassmorphic contentClassName="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<ChatCircleDotsIcon className="mt-0.5 h-4 w-4 text-blue-500" />
|
||||
<div>
|
||||
<Typography className="text-sm font-semibold">AI topic assignment</Typography>
|
||||
<Typography variant="secondary" className="mt-1 text-sm">{message.aiReason}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</DesignCard>
|
||||
</div>
|
||||
<div className="border-t border-border/60 p-5 lg:border-l lg:border-t-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<ContactAvatar contact={contact} />
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/projects/${encodeURIComponent(projectId)}/contacts/${encodeURIComponent(contact.id)}`}
|
||||
className="text-sm font-semibold text-foreground hover:underline"
|
||||
>
|
||||
{contact.name}
|
||||
</Link>
|
||||
<ContactIdentityLine contact={contact} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-3 text-sm">
|
||||
<InfoRow label="Topic" value={topic.title} />
|
||||
<InfoRow label="Owner" value={topic.owner} />
|
||||
<InfoRow label="Lifecycle" value={contact.lifecycle} />
|
||||
<InfoRow label="Contact score" value={`${contact.score}/100`} />
|
||||
</div>
|
||||
<DesignButton className="mt-5 w-full" size="sm">
|
||||
Reply in topic
|
||||
</DesignButton>
|
||||
</div>
|
||||
</div>
|
||||
</DesignCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoRow({ label, value }: { label: string, value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<Typography variant="secondary" className="text-xs">{label}</Typography>
|
||||
<Typography className="min-w-0 truncate text-right text-xs font-medium">{value}</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: { message: CommsMessage }) {
|
||||
const contact = getCommsContact(message.contactId);
|
||||
if (contact == null) {
|
||||
throw new Error(`Mock message ${message.id} references missing contact ${message.contactId}.`);
|
||||
}
|
||||
const Icon = getPlatformIcon(message.platform);
|
||||
const isOutbound = message.direction === "outbound";
|
||||
return (
|
||||
<div className={cn("flex gap-3", isOutbound && "flex-row-reverse")}>
|
||||
<div className="relative">
|
||||
<ContactAvatar contact={contact} size="sm" />
|
||||
<div className="absolute -bottom-1 -right-1 rounded-full bg-background p-0.5 ring-1 ring-border">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("max-w-[78%] rounded-2xl border p-3", isOutbound ? "border-blue-500/30 bg-blue-500/[0.08]" : "border-border/60 bg-foreground/[0.03]")}>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Typography className="text-xs font-semibold">{isOutbound ? "Your team" : contact.name}</Typography>
|
||||
<Typography variant="secondary" className="text-[11px]">{COMMS_PLATFORM_LABELS[message.platform]}</Typography>
|
||||
</div>
|
||||
{message.subject != null ? <Typography className="mb-1 text-xs font-semibold">{message.subject}</Typography> : null}
|
||||
<Typography className="whitespace-pre-wrap text-sm leading-6">{message.text}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommsComposer({
|
||||
initialPlatform = "email",
|
||||
initialContactId = COMMS_CONTACTS[0]?.id ?? "",
|
||||
submitLabel = "Save draft",
|
||||
compact = false,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialPlatform?: CommsPlatform,
|
||||
initialContactId?: string,
|
||||
submitLabel?: string,
|
||||
compact?: boolean,
|
||||
onSubmit: (value: ComposerValue) => void,
|
||||
}) {
|
||||
const [platform, setPlatform] = useState<CommsPlatform>(initialPlatform);
|
||||
const [contactId, setContactId] = useState(initialContactId);
|
||||
const contact = getCommsContact(contactId) ?? COMMS_CONTACTS[0];
|
||||
if (contact == null) {
|
||||
throw new Error("Expected at least one mock Comms contact.");
|
||||
}
|
||||
const defaultRecipient = contact.channels.emails[0] ?? contact.channels.discordIds[0] ?? contact.channels.slackIds[0] ?? "";
|
||||
const [recipients, setRecipients] = useState(defaultRecipient);
|
||||
const [channelLabel, setChannelLabel] = useState(getDefaultChannelLabel(platform, contact));
|
||||
const [subject, setSubject] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [attachments, setAttachments] = useState("");
|
||||
|
||||
const platformHelp = getComposerHelp(platform);
|
||||
const selectedContactOptions = useMemo(() => COMMS_CONTACTS.map((item) => ({ value: item.id, label: `${item.name} · ${item.company}` })), []);
|
||||
|
||||
return (
|
||||
<DesignCard glassmorphic contentClassName={cn("space-y-4", compact ? "p-4" : "p-5")}>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Platform</Typography>
|
||||
<DesignSelectorDropdown
|
||||
value={platform}
|
||||
options={COMMS_PLATFORM_OPTIONS}
|
||||
onValueChange={(value) => {
|
||||
const nextPlatform = parsePlatform(value);
|
||||
setPlatform(nextPlatform);
|
||||
setChannelLabel(getDefaultChannelLabel(nextPlatform, contact));
|
||||
}}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Contact</Typography>
|
||||
<DesignSelectorDropdown
|
||||
value={contactId}
|
||||
options={selectedContactOptions}
|
||||
onValueChange={(value) => {
|
||||
const nextContact = getCommsContact(value);
|
||||
if (nextContact == null) {
|
||||
throw new Error(`Unknown mock contact ${value}`);
|
||||
}
|
||||
setContactId(nextContact.id);
|
||||
setRecipients(nextContact.channels.emails[0] ?? nextContact.channels.discordIds[0] ?? nextContact.channels.slackIds[0] ?? "");
|
||||
setChannelLabel(getDefaultChannelLabel(platform, nextContact));
|
||||
}}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Typography variant="secondary" className="text-sm">{platformHelp}</Typography>
|
||||
|
||||
{platform === "email" ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field label="Recipients">
|
||||
<DesignInput value={recipients} onChange={(event) => setRecipients(event.target.value)} placeholder="customer@example.com" />
|
||||
</Field>
|
||||
<Field label="Subject">
|
||||
<DesignInput value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Subject" />
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{platform === "discord" || platform === "slack" || platform === "support" ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field label={platform === "support" ? "Ticket" : "Channel / thread"}>
|
||||
<DesignInput value={channelLabel} onChange={(event) => setChannelLabel(event.target.value)} placeholder="Choose destination" />
|
||||
</Field>
|
||||
<Field label="Reply context">
|
||||
<DesignInput value={recipients} onChange={(event) => setRecipients(event.target.value)} placeholder="Contact handle or message id" />
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{platform === "push" ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field label="Push audience">
|
||||
<DesignInput value={channelLabel} onChange={(event) => setChannelLabel(event.target.value)} placeholder="Production Android cohort" />
|
||||
</Field>
|
||||
<Field label="Title">
|
||||
<DesignInput value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Notification title" />
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field label={platform === "email" ? "HTML email body" : "Message"}>
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder={platform === "email" ? "<p>Write the email body...</p>" : "Write the message..."}
|
||||
className="min-h-32 rounded-xl"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Attachments">
|
||||
<DesignInput value={attachments} onChange={(event) => setAttachments(event.target.value)} placeholder="proposal.pdf, screenshot.png" />
|
||||
</Field>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<DesignButton
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSubject("");
|
||||
setBody("");
|
||||
setAttachments("");
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</DesignButton>
|
||||
<DesignButton
|
||||
onClick={() => onSubmit({ platform, contactId, recipients, channelLabel, subject, body, attachments })}
|
||||
disabled={body.trim() === ""}
|
||||
>
|
||||
<PaperPlaneTiltIcon className="h-4 w-4" />
|
||||
{submitLabel}
|
||||
</DesignButton>
|
||||
</div>
|
||||
</DesignCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function DraftCard({ draft }: { draft: CommsDraft }) {
|
||||
const contact = getCommsContact(draft.contactId);
|
||||
if (contact == null) {
|
||||
throw new Error(`Mock draft ${draft.id} references missing contact ${draft.contactId}.`);
|
||||
}
|
||||
return (
|
||||
<DesignCard glassmorphic contentClassName="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<PlatformBadge platform={draft.platform} />
|
||||
<DesignBadge label={draft.status} color={draft.status === "scheduled" ? "purple" : "blue"} size="sm" />
|
||||
</div>
|
||||
<Typography className="truncate text-sm font-semibold">{draft.title}</Typography>
|
||||
<Typography variant="secondary" className="mt-1 truncate text-xs">
|
||||
{contact.name} · {draft.channelLabel} · Updated {formatCommsTimestamp(draft.updatedAt)}
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="mt-3 line-clamp-2 text-sm">{draft.body}</Typography>
|
||||
</div>
|
||||
<DesignMenu
|
||||
variant="actions"
|
||||
trigger="icon"
|
||||
triggerLabel="Draft actions"
|
||||
align="end"
|
||||
items={[
|
||||
{ id: "edit", label: "Edit draft" },
|
||||
{ id: "duplicate", label: "Duplicate" },
|
||||
{ id: "delete", label: "Delete", itemVariant: "destructive" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</DesignCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function MergeOverrideMenu({ label, onMerge, onSplit }: { label: string, onMerge: () => void, onSplit: () => void }) {
|
||||
return (
|
||||
<DesignMenu
|
||||
variant="actions"
|
||||
trigger="button"
|
||||
triggerLabel={label}
|
||||
align="end"
|
||||
withIcons
|
||||
items={[
|
||||
{ id: "merge", label: "Merge selected records", icon: <GitMergeIcon className="h-4 w-4" />, onClick: onMerge },
|
||||
{ id: "split", label: "Split into new record", icon: <UserCircleIcon className="h-4 w-4" />, onClick: onSplit },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string, children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</Typography>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePlatform(value: string): CommsPlatform {
|
||||
if (value === "email" || value === "slack" || value === "discord" || value === "support" || value === "push") {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unknown Comms platform ${value}`);
|
||||
}
|
||||
|
||||
function getDefaultChannelLabel(platform: CommsPlatform, contact: CommsContact): string {
|
||||
if (platform === "email") return contact.channels.emails[0] ?? "";
|
||||
if (platform === "slack") return contact.channels.slackIds[0] != null ? `Shared Slack / ${contact.channels.slackIds[0]}` : "Shared Slack / #general";
|
||||
if (platform === "discord") return contact.channels.discordIds[0] != null ? `Discord / ${contact.channels.discordIds[0]}` : "Discord / #general";
|
||||
if (platform === "support") return contact.channels.supportIds[0] ?? "support:new-ticket";
|
||||
return contact.channels.pushTokens[0] ?? "All opted-in devices";
|
||||
}
|
||||
|
||||
function getComposerHelp(platform: CommsPlatform): string {
|
||||
if (platform === "email") {
|
||||
return "Email supports recipients, a subject, attachments, and a richer body. This mock keeps the editor lightweight for the presentation.";
|
||||
}
|
||||
if (platform === "discord") {
|
||||
return "Discord replies require a server/channel/thread or an incoming Discord message to anchor the response.";
|
||||
}
|
||||
if (platform === "slack") {
|
||||
return "Shared Slack replies can target channels, threads, or unthreaded account rooms.";
|
||||
}
|
||||
if (platform === "support") {
|
||||
return "Support ticket replies stay attached to the external ticket id and can include ticket-safe attachments.";
|
||||
}
|
||||
return "Push is send-only, so the composer asks for an audience and notification title instead of a reply target.";
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { DesignAlert, DesignBadge, DesignCard } from "@/components/design-components";
|
||||
import { Typography } from "@/components/ui";
|
||||
import { COMMS_DRAFTS, COMMS_PLATFORM_LABELS, type CommsDraft } from "@/lib/comms-mock";
|
||||
import { NotePencilIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { AppEnabledGuard } from "../../app-enabled-guard";
|
||||
import { PageLayout } from "../../page-layout";
|
||||
import { CommsComposer, DraftCard } from "../comms-components";
|
||||
|
||||
export default function PageClient() {
|
||||
const [drafts, setDrafts] = useState<CommsDraft[]>([...COMMS_DRAFTS]);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<AppEnabledGuard appId="authentication">
|
||||
<PageLayout
|
||||
title="Drafts"
|
||||
description="Compose and save channel-specific messages before sending them through Comms."
|
||||
fillWidth
|
||||
>
|
||||
{notice != null ? <DesignAlert variant="success" description={notice} /> : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<div className="space-y-4">
|
||||
<DesignCard title="New draft" subtitle="The editor adapts to each platform instead of flattening every channel into email." icon={NotePencilIcon} gradient="default" glassmorphic>
|
||||
<CommsComposer
|
||||
submitLabel="Save draft"
|
||||
onSubmit={(value) => {
|
||||
const nextDraft: CommsDraft = {
|
||||
id: `draft-mock-${drafts.length + 1}`,
|
||||
platform: value.platform,
|
||||
title: value.subject.trim() || `${COMMS_PLATFORM_LABELS[value.platform]} message`,
|
||||
contactId: value.contactId,
|
||||
recipients: value.recipients.split(",").map((recipient) => recipient.trim()).filter((recipient) => recipient !== ""),
|
||||
channelLabel: value.channelLabel,
|
||||
subject: value.subject,
|
||||
body: value.body,
|
||||
updatedAt: new Date().toISOString(),
|
||||
status: "draft",
|
||||
};
|
||||
setDrafts((current) => [nextDraft, ...current]);
|
||||
setNotice(`Saved ${COMMS_PLATFORM_LABELS[value.platform]} draft for the presentation mock.`);
|
||||
}}
|
||||
/>
|
||||
</DesignCard>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<DesignCard glassmorphic contentClassName="p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<PaperPlaneTiltIcon className="mt-0.5 h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<Typography className="text-sm font-semibold">Draft queue</Typography>
|
||||
<Typography variant="secondary" className="mt-1 text-sm">
|
||||
Drafts are local mock records. This lets the presentation show creation without requiring a Comms backend.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<DesignBadge label={`${drafts.length} drafts`} color="blue" size="sm" />
|
||||
<DesignBadge label={`${drafts.filter((draft) => draft.status === "scheduled").length} scheduled`} color="purple" size="sm" />
|
||||
</div>
|
||||
</DesignCard>
|
||||
|
||||
<div className="space-y-3">
|
||||
{drafts.map((draft) => (
|
||||
<DraftCard key={draft.id} draft={draft} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Comms Drafts",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { DesignBadge, DesignButton, DesignDialog, DesignSelectorDropdown } from "@/components/design-components";
|
||||
import { Typography, cn } from "@/components/ui";
|
||||
import {
|
||||
COMMS_PLATFORM_OPTIONS,
|
||||
type CommsDirection,
|
||||
type CommsMessage,
|
||||
type CommsPlatform,
|
||||
formatCommsTimestamp,
|
||||
getCommsContact,
|
||||
getCommsTopic,
|
||||
useCommsMessages,
|
||||
} from "@/lib/comms-mock";
|
||||
import { ArrowsClockwiseIcon, FunnelIcon } from "@phosphor-icons/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AppEnabledGuard } from "../app-enabled-guard";
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { useProjectId } from "../use-admin-app";
|
||||
import { ContactAvatar, MessageDetailPanel, PlatformBadge } from "./comms-components";
|
||||
|
||||
const directionOptions = [
|
||||
{ value: "all", label: "All directions" },
|
||||
{ value: "inbound", label: "Inbound" },
|
||||
{ value: "outbound", label: "Sent" },
|
||||
];
|
||||
|
||||
const platformOptions = [
|
||||
{ value: "all", label: "All platforms" },
|
||||
...COMMS_PLATFORM_OPTIONS,
|
||||
];
|
||||
|
||||
export default function PageClient() {
|
||||
const projectId = useProjectId();
|
||||
const messages = useCommsMessages();
|
||||
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null);
|
||||
const [platformFilter, setPlatformFilter] = useState<CommsPlatform | "all">("all");
|
||||
const [directionFilter, setDirectionFilter] = useState<CommsDirection | "all">("all");
|
||||
|
||||
const filteredMessages = useMemo(() => {
|
||||
return messages.filter((message) => {
|
||||
const platformMatches = platformFilter === "all" || message.platform === platformFilter;
|
||||
const directionMatches = directionFilter === "all" || message.direction === directionFilter;
|
||||
return platformMatches && directionMatches;
|
||||
});
|
||||
}, [directionFilter, messages, platformFilter]);
|
||||
|
||||
const selectedMessage = selectedMessageId == null
|
||||
? null
|
||||
: messages.find((message) => message.id === selectedMessageId) ?? null;
|
||||
|
||||
return (
|
||||
<AppEnabledGuard appId="authentication">
|
||||
<PageLayout
|
||||
title="Message Stream"
|
||||
description="A live unified feed of inbound and outbound customer communication across every channel."
|
||||
fillWidth
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DesignBadge label="Live mock feed" color="green" size="sm" />
|
||||
<DesignButton variant="outline" size="sm">
|
||||
<ArrowsClockwiseIcon className="h-4 w-4" />
|
||||
Refresh view
|
||||
</DesignButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="sticky top-0 z-10 flex flex-col gap-2 border-b border-border/50 bg-background/95 px-3 py-2 backdrop-blur sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wide text-foreground/70">
|
||||
Messages
|
||||
</Typography>
|
||||
<DesignBadge label={`${filteredMessages.length} shown`} color="blue" size="sm" />
|
||||
<Typography variant="secondary" className="hidden text-xs sm:inline">
|
||||
New inbound mock messages appear every few seconds.
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DesignSelectorDropdown
|
||||
value={platformFilter}
|
||||
options={platformOptions}
|
||||
onValueChange={(value) => setPlatformFilter(parsePlatformFilter(value))}
|
||||
size="sm"
|
||||
/>
|
||||
<DesignSelectorDropdown
|
||||
value={directionFilter}
|
||||
options={directionOptions}
|
||||
onValueChange={(value) => setDirectionFilter(parseDirectionFilter(value))}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/50 border-b border-border/50">
|
||||
{filteredMessages.map((message) => (
|
||||
<CompactMessageRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
onSelect={() => setSelectedMessageId(message.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredMessages.length === 0 ? (
|
||||
<div className="px-4 py-16 text-center">
|
||||
<FunnelIcon className="mx-auto h-8 w-8 text-muted-foreground" />
|
||||
<Typography className="mt-3 text-sm font-medium">No messages match these filters</Typography>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DesignDialog
|
||||
open={selectedMessage != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectedMessageId(null);
|
||||
}
|
||||
}}
|
||||
title={selectedMessage?.subject ?? "Message detail"}
|
||||
description="Channel payload, contact context, and AI topic assignment"
|
||||
size="5xl"
|
||||
noBodyPadding
|
||||
>
|
||||
{selectedMessage != null ? (
|
||||
<MessageDetailPanel message={selectedMessage} projectId={projectId} />
|
||||
) : null}
|
||||
</DesignDialog>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactMessageRow({ message, onSelect }: { message: CommsMessage, onSelect: () => void }) {
|
||||
const contact = getCommsContact(message.contactId);
|
||||
const topic = getCommsTopic(message.topicId);
|
||||
if (contact == null || topic == null) {
|
||||
throw new Error(`Mock message ${message.id} references a missing contact or topic.`);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[2.5rem_minmax(9rem,13rem)_7.5rem_minmax(0,1fr)_8rem] items-center gap-3 px-3 py-2 text-left",
|
||||
"text-sm transition-colors duration-150 hover:bg-muted/60 hover:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
)}
|
||||
>
|
||||
<ContactAvatar contact={contact} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<Typography className="truncate text-sm font-medium">{contact.name}</Typography>
|
||||
<Typography variant="secondary" className="truncate text-[11px]">{contact.company}</Typography>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<PlatformBadge platform={message.platform} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Typography className="truncate text-sm font-medium">{message.subject ?? topic.title}</Typography>
|
||||
{message.direction === "inbound" ? <DesignBadge label="Inbound" color="green" size="sm" /> : null}
|
||||
{message.urgency === "high" ? <DesignBadge label="High" color="orange" size="sm" /> : null}
|
||||
</div>
|
||||
<Typography variant="secondary" className="truncate text-xs">{message.text}</Typography>
|
||||
</div>
|
||||
<Typography variant="secondary" className="truncate text-right text-[11px] tabular-nums">
|
||||
{formatCommsTimestamp(message.timestamp)}
|
||||
</Typography>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePlatformFilter(value: string): CommsPlatform | "all" {
|
||||
if (value === "all" || value === "email" || value === "slack" || value === "discord" || value === "support" || value === "push") {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unknown platform filter ${value}`);
|
||||
}
|
||||
|
||||
function parseDirectionFilter(value: string): CommsDirection | "all" {
|
||||
if (value === "all" || value === "inbound" || value === "outbound") {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unknown direction filter ${value}`);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Comms Message Stream",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { DesignAlert, DesignBadge, DesignButton, DesignCard } from "@/components/design-components";
|
||||
import { Typography, cn } from "@/components/ui";
|
||||
import { COMMS_TOPICS, type CommsMessage, type CommsTopic, useCommsMessages } from "@/lib/comms-mock";
|
||||
import { GitMergeIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AppEnabledGuard } from "../../app-enabled-guard";
|
||||
import { PageLayout } from "../../page-layout";
|
||||
import { CommsComposer, MergeOverrideMenu, MessageBubble } from "../comms-components";
|
||||
|
||||
export default function PageClient() {
|
||||
const liveMessages = useCommsMessages();
|
||||
const [topics, setTopics] = useState<CommsTopic[]>([...COMMS_TOPICS]);
|
||||
const [selectedTopicId, setSelectedTopicId] = useState(COMMS_TOPICS[0]?.id ?? "");
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const selectedTopic = topics.find((topic) => topic.id === selectedTopicId) ?? topics[0] ?? null;
|
||||
const topicMessages = useMemo(() => {
|
||||
if (selectedTopic == null) {
|
||||
return [];
|
||||
}
|
||||
return liveMessages
|
||||
.filter((message) => message.topicId === selectedTopic.id || selectedTopic.messageIds.includes(message.id))
|
||||
.sort(compareMessageTimestampAscending);
|
||||
}, [liveMessages, selectedTopic]);
|
||||
|
||||
return (
|
||||
<AppEnabledGuard appId="authentication">
|
||||
<PageLayout
|
||||
title="Topics"
|
||||
description="AI groups inbound and outbound communication into topics, with manual override controls for merge and split mistakes."
|
||||
fillWidth
|
||||
>
|
||||
{notice != null ? <DesignAlert variant="info" description={notice} /> : null}
|
||||
<div className="grid min-h-0 gap-4 xl:grid-cols-[380px_1fr]">
|
||||
<DesignCard glassmorphic contentClassName="flex min-h-[720px] flex-col gap-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SparkleIcon className="h-4 w-4 text-purple-500" />
|
||||
<Typography className="text-sm font-semibold">AI topics</Typography>
|
||||
</div>
|
||||
<Typography variant="secondary" className="mt-1 text-xs">Auto-categorized from every channel.</Typography>
|
||||
</div>
|
||||
<DesignBadge label={topics.length.toString()} color="purple" size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
{topics.map((topic) => (
|
||||
<button
|
||||
key={topic.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTopicId(topic.id)}
|
||||
className={cn(
|
||||
"w-full rounded-2xl border p-4 text-left transition-colors duration-150 hover:bg-foreground/[0.04] hover:transition-none",
|
||||
selectedTopic?.id === topic.id ? "border-purple-500/40 bg-purple-500/[0.07]" : "border-border/60 bg-background/70"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Typography className="min-w-0 text-sm font-semibold">{topic.title}</Typography>
|
||||
<DesignBadge label={topic.status} color={topic.status === "resolved" ? "green" : topic.status === "waiting" ? "orange" : "blue"} size="sm" />
|
||||
</div>
|
||||
<Typography variant="secondary" className="mt-2 line-clamp-2 text-xs">{topic.summary}</Typography>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<DesignBadge label={`${topic.confidence}% confidence`} color="purple" size="sm" />
|
||||
<DesignBadge label={`${topic.messageIds.length} seed messages`} color="blue" size="sm" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DesignCard>
|
||||
|
||||
{selectedTopic != null ? (
|
||||
<DesignCard glassmorphic contentClassName="flex min-h-[720px] flex-col p-0">
|
||||
<div className="border-b border-border/60 p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{selectedTopic.labels.map((label) => <DesignBadge key={label} label={label} color="cyan" size="sm" />)}
|
||||
</div>
|
||||
<Typography className="text-lg font-semibold">{selectedTopic.title}</Typography>
|
||||
<Typography variant="secondary" className="mt-1 max-w-3xl text-sm">{selectedTopic.summary}</Typography>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<MergeOverrideMenu
|
||||
label="Override AI"
|
||||
onMerge={() => {
|
||||
const otherTopic = topics.find((topic) => topic.id !== selectedTopic.id);
|
||||
if (otherTopic == null) {
|
||||
setNotice("No other mock topic is available to merge.");
|
||||
return;
|
||||
}
|
||||
setTopics((current) => current.filter((topic) => topic.id !== otherTopic.id));
|
||||
setNotice(`Mock merged "${otherTopic.title}" into "${selectedTopic.title}".`);
|
||||
}}
|
||||
onSplit={() => {
|
||||
setNotice(`Mock split prepared for "${selectedTopic.title}". In the real app, selected messages would move into a new topic.`);
|
||||
}}
|
||||
/>
|
||||
<DesignButton variant="outline" size="sm">
|
||||
<GitMergeIcon className="h-4 w-4" />
|
||||
Merge topics
|
||||
</DesignButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||
{topicMessages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 p-5">
|
||||
<Typography className="mb-3 text-sm font-semibold">Respond in this topic</Typography>
|
||||
<CommsComposer
|
||||
compact
|
||||
submitLabel="Send mock reply"
|
||||
initialContactId={selectedTopic.contactIds[0]}
|
||||
onSubmit={(value) => {
|
||||
setNotice(`Mock ${value.platform} reply composed for "${selectedTopic.title}".`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DesignCard>
|
||||
) : (
|
||||
<DesignCard glassmorphic contentClassName="flex min-h-[720px] items-center justify-center p-8 text-center">
|
||||
<Typography variant="secondary">Select a topic to inspect its conversation and compose a response.</Typography>
|
||||
</DesignCard>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function compareMessageTimestampAscending(a: CommsMessage, b: CommsMessage) {
|
||||
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Comms Topics",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { Link } from "@/components/link";
|
||||
import { DesignAlert, DesignBadge, DesignButton, DesignCard, DesignEditableGrid, DesignSelectorDropdown } from "@/components/design-components";
|
||||
import { Typography } from "@/components/ui";
|
||||
import { COMMS_CONTACTS, COMMS_TOPICS, getCommsContact, useCommsMessages, type CommsContact } from "@/lib/comms-mock";
|
||||
import { ArrowLeftIcon, GitMergeIcon, IdentificationCardIcon, LinkSimpleIcon, NotePencilIcon } from "@phosphor-icons/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AppEnabledGuard } from "../../app-enabled-guard";
|
||||
import { PageLayout } from "../../page-layout";
|
||||
import { useProjectId } from "../../use-admin-app";
|
||||
import { ContactAvatar, InfoRow, MessageRow } from "../../comms/comms-components";
|
||||
|
||||
export default function PageClient() {
|
||||
const projectId = useProjectId();
|
||||
const contactId = useContactIdFromPathname();
|
||||
const contact = getCommsContact(contactId);
|
||||
const messages = useCommsMessages();
|
||||
const [mergeTargetId, setMergeTargetId] = useState(COMMS_CONTACTS.find((item) => item.id !== contactId)?.id ?? "");
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const contactMessages = useMemo(() => messages.filter((message) => message.contactId === contactId), [contactId, messages]);
|
||||
const relatedTopics = useMemo(() => COMMS_TOPICS.filter((topic) => topic.contactIds.includes(contactId)), [contactId]);
|
||||
|
||||
if (contact == null) {
|
||||
return (
|
||||
<AppEnabledGuard appId="authentication">
|
||||
<PageLayout title="Contact not found">
|
||||
<DesignAlert variant="error" description="This mock contact does not exist." />
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
);
|
||||
}
|
||||
|
||||
const mergeTarget = getCommsContact(mergeTargetId);
|
||||
const mergedPreview = mergeTarget == null ? null : getMergedPreview(contact, mergeTarget);
|
||||
|
||||
return (
|
||||
<AppEnabledGuard appId="authentication">
|
||||
<PageLayout
|
||||
title={contact.name}
|
||||
description={`${contact.role} at ${contact.company}`}
|
||||
fillWidth
|
||||
actions={
|
||||
<DesignButton asChild variant="outline" size="sm">
|
||||
<Link href={`/projects/${encodeURIComponent(projectId)}/contacts`}>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Contacts
|
||||
</Link>
|
||||
</DesignButton>
|
||||
}
|
||||
>
|
||||
{notice != null ? <DesignAlert variant="success" description={notice} /> : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<div className="space-y-4">
|
||||
<DesignCard glassmorphic contentClassName="p-5">
|
||||
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<ContactAvatar contact={contact} size="lg" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Typography className="text-2xl font-semibold">{contact.name}</Typography>
|
||||
<DesignBadge label={contact.lifecycle} color={contact.lifecycle === "At risk" ? "orange" : "blue"} size="sm" />
|
||||
{contact.userId != null ? <DesignBadge label="User-linked" color="green" size="sm" /> : <DesignBadge label="Contact-only" color="blue" size="sm" />}
|
||||
</div>
|
||||
<Typography variant="secondary" className="mt-1 text-sm">
|
||||
{contact.role} · {contact.company} · Owned by {contact.owner}
|
||||
</Typography>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{contact.tags.map((tag) => <DesignBadge key={tag} label={tag} color="cyan" size="sm" />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-foreground/[0.03] p-4 text-center">
|
||||
<Typography variant="secondary" className="text-xs font-semibold uppercase tracking-wider">Contact score</Typography>
|
||||
<Typography className="mt-1 text-3xl font-semibold">{contact.score}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</DesignCard>
|
||||
|
||||
<DesignCard title="CRM fields" subtitle="Editable CRM-style details for the contact primitive." icon={IdentificationCardIcon} gradient="default" glassmorphic>
|
||||
<DesignEditableGrid
|
||||
columns={2}
|
||||
items={[
|
||||
{ icon: <IdentificationCardIcon className="h-4 w-4" />, name: "Lifecycle", type: "text", value: contact.lifecycle, readOnly: true },
|
||||
{ icon: <IdentificationCardIcon className="h-4 w-4" />, name: "Owner", type: "text", value: contact.owner, readOnly: true },
|
||||
{ icon: <IdentificationCardIcon className="h-4 w-4" />, name: "Source", type: "text", value: contact.source, readOnly: true },
|
||||
{ icon: <IdentificationCardIcon className="h-4 w-4" />, name: "Linked user", type: "text", value: contact.userId ?? "No product user yet", readOnly: true },
|
||||
]}
|
||||
/>
|
||||
</DesignCard>
|
||||
|
||||
<DesignCard title="Channel identities" subtitle="Contacts can carry many handles per platform. Merging contacts combines these fields." icon={LinkSimpleIcon} gradient="default" glassmorphic>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<ChannelSection title="Emails" values={contact.channels.emails} empty="No emails yet" />
|
||||
<ChannelSection title="Slack IDs" values={contact.channels.slackIds} empty="No Slack IDs yet" />
|
||||
<ChannelSection title="Discord IDs" values={contact.channels.discordIds} empty="No Discord IDs yet" />
|
||||
<ChannelSection title="Support IDs" values={contact.channels.supportIds} empty="No support IDs yet" />
|
||||
<ChannelSection title="Push tokens" values={contact.channels.pushTokens} empty="No push tokens yet" />
|
||||
</div>
|
||||
</DesignCard>
|
||||
|
||||
<DesignCard title="Recent communication" subtitle="Messages tied to this contact across every platform." icon={NotePencilIcon} gradient="default" glassmorphic>
|
||||
<div className="space-y-3">
|
||||
{contactMessages.map((message) => (
|
||||
<MessageRow key={message.id} message={message} selected={false} onSelect={() => setNotice(`Selected mock message ${message.id}.`)} />
|
||||
))}
|
||||
</div>
|
||||
</DesignCard>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<DesignCard title="Merge contacts" subtitle="Mock manual identity resolution for duplicate CRM/channel records." icon={GitMergeIcon} gradient="default" glassmorphic>
|
||||
<div className="space-y-4">
|
||||
<DesignSelectorDropdown
|
||||
value={mergeTargetId}
|
||||
options={COMMS_CONTACTS.filter((item) => item.id !== contact.id).map((item) => ({ value: item.id, label: `${item.name} · ${item.company}` }))}
|
||||
onValueChange={setMergeTargetId}
|
||||
size="md"
|
||||
/>
|
||||
{mergedPreview != null ? (
|
||||
<div className="space-y-2 rounded-2xl border border-border/60 bg-foreground/[0.03] p-4">
|
||||
<InfoRow label="Emails after merge" value={mergedPreview.emailCount.toString()} />
|
||||
<InfoRow label="Slack IDs after merge" value={mergedPreview.slackCount.toString()} />
|
||||
<InfoRow label="Discord IDs after merge" value={mergedPreview.discordCount.toString()} />
|
||||
<InfoRow label="Support IDs after merge" value={mergedPreview.supportCount.toString()} />
|
||||
</div>
|
||||
) : null}
|
||||
<DesignButton
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (mergeTarget == null) {
|
||||
throw new Error("Expected merge target to exist before merging mock contacts.");
|
||||
}
|
||||
setNotice(`Mock merged ${mergeTarget.name} into ${contact.name}. Channel fields would be combined in the real app.`);
|
||||
}}
|
||||
>
|
||||
<GitMergeIcon className="h-4 w-4" />
|
||||
Merge in mock
|
||||
</DesignButton>
|
||||
</div>
|
||||
</DesignCard>
|
||||
|
||||
<DesignCard glassmorphic contentClassName="space-y-3 p-5">
|
||||
<Typography className="text-sm font-semibold">Related topics</Typography>
|
||||
{relatedTopics.map((topic) => (
|
||||
<div key={topic.id} className="rounded-2xl border border-border/60 bg-foreground/[0.03] p-3">
|
||||
<Typography className="text-sm font-semibold">{topic.title}</Typography>
|
||||
<Typography variant="secondary" className="mt-1 line-clamp-2 text-xs">{topic.summary}</Typography>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<DesignBadge label={`${topic.confidence}% AI`} color="purple" size="sm" />
|
||||
<DesignBadge label={topic.status} color="blue" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</DesignCard>
|
||||
|
||||
<DesignCard glassmorphic contentClassName="space-y-3 p-5">
|
||||
<Typography className="text-sm font-semibold">Notes</Typography>
|
||||
{contact.notes.map((note) => (
|
||||
<Typography key={note} variant="secondary" className="rounded-2xl border border-border/60 bg-foreground/[0.03] p-3 text-sm">
|
||||
{note}
|
||||
</Typography>
|
||||
))}
|
||||
</DesignCard>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function useContactIdFromPathname(): string {
|
||||
const pathname = usePathname();
|
||||
const marker = "/contacts/";
|
||||
const index = pathname.indexOf(marker);
|
||||
if (index === -1) {
|
||||
throw new Error("Expected contact detail page pathname to include /contacts/.");
|
||||
}
|
||||
return decodeURIComponent(pathname.slice(index + marker.length).split("/")[0] ?? "");
|
||||
}
|
||||
|
||||
function ChannelSection({ title, values, empty }: { title: string, values: readonly string[], empty: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-foreground/[0.03] p-4">
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{title}</Typography>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{values.length === 0 ? <DesignBadge label={empty} color="blue" size="sm" /> : values.map((value) => <DesignBadge key={value} label={value} color="blue" size="sm" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getMergedPreview(primary: CommsContact, secondary: CommsContact) {
|
||||
return {
|
||||
emailCount: new Set([...primary.channels.emails, ...secondary.channels.emails]).size,
|
||||
slackCount: new Set([...primary.channels.slackIds, ...secondary.channels.slackIds]).size,
|
||||
discordCount: new Set([...primary.channels.discordIds, ...secondary.channels.discordIds]).size,
|
||||
supportCount: new Set([...primary.channels.supportIds, ...secondary.channels.supportIds]).size,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Contact Details",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { DesignBadge, DesignCard, DesignInput, DesignSelectorDropdown } from "@/components/design-components";
|
||||
import { Typography } from "@/components/ui";
|
||||
import { COMMS_CONTACTS, type CommsContact } from "@/lib/comms-mock";
|
||||
import { AddressBookIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AppEnabledGuard } from "../app-enabled-guard";
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { useProjectId } from "../use-admin-app";
|
||||
import { ContactMiniCard } from "../comms/comms-components";
|
||||
|
||||
const lifecycleOptions = [
|
||||
{ value: "all", label: "All lifecycles" },
|
||||
{ value: "Lead", label: "Lead" },
|
||||
{ value: "Customer", label: "Customer" },
|
||||
{ value: "Champion", label: "Champion" },
|
||||
{ value: "At risk", label: "At risk" },
|
||||
];
|
||||
|
||||
export default function PageClient() {
|
||||
const projectId = useProjectId();
|
||||
const [query, setQuery] = useState("");
|
||||
const [lifecycle, setLifecycle] = useState<CommsContact["lifecycle"] | "all">("all");
|
||||
|
||||
const contacts = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return COMMS_CONTACTS.filter((contact) => {
|
||||
const lifecycleMatches = lifecycle === "all" || contact.lifecycle === lifecycle;
|
||||
const queryMatches = normalizedQuery === ""
|
||||
|| contact.name.toLowerCase().includes(normalizedQuery)
|
||||
|| contact.company.toLowerCase().includes(normalizedQuery)
|
||||
|| contact.tags.some((tag) => tag.toLowerCase().includes(normalizedQuery));
|
||||
return lifecycleMatches && queryMatches;
|
||||
});
|
||||
}, [lifecycle, query]);
|
||||
|
||||
const userBackedCount = COMMS_CONTACTS.filter((contact) => contact.userId != null).length;
|
||||
const channelCount = COMMS_CONTACTS.reduce((sum, contact) => {
|
||||
return sum
|
||||
+ contact.channels.emails.length
|
||||
+ contact.channels.slackIds.length
|
||||
+ contact.channels.discordIds.length
|
||||
+ contact.channels.supportIds.length
|
||||
+ contact.channels.pushTokens.length;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<AppEnabledGuard appId="authentication">
|
||||
<PageLayout
|
||||
title="Contacts"
|
||||
description="CRM-style contact records for everyone your company has interacted with, including users and channel-only identities."
|
||||
fillWidth
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<MetricCard label="Contacts" value={COMMS_CONTACTS.length.toString()} detail="Mock CRM identities" />
|
||||
<MetricCard label="User-backed" value={userBackedCount.toString()} detail="Automatically also contacts" />
|
||||
<MetricCard label="Channel handles" value={channelCount.toString()} detail="Emails, Slack, Discord, tickets, push" />
|
||||
</div>
|
||||
|
||||
<DesignCard glassmorphic contentClassName="space-y-4 p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AddressBookIcon className="h-5 w-5 text-blue-500" />
|
||||
<Typography className="text-sm font-semibold">Contact directory</Typography>
|
||||
<DesignBadge label={`${contacts.length} shown`} color="blue" size="sm" />
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(220px,1fr)_180px]">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<DesignInput
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search contacts"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<DesignSelectorDropdown
|
||||
value={lifecycle}
|
||||
options={lifecycleOptions}
|
||||
onValueChange={(value) => setLifecycle(parseLifecycleFilter(value))}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{contacts.map((contact) => (
|
||||
<ContactMiniCard key={contact.id} contact={contact} projectId={projectId} />
|
||||
))}
|
||||
</div>
|
||||
</DesignCard>
|
||||
</PageLayout>
|
||||
</AppEnabledGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, detail }: { label: string, value: string, detail: string }) {
|
||||
return (
|
||||
<DesignCard glassmorphic contentClassName="p-5">
|
||||
<Typography variant="secondary" className="text-xs font-semibold uppercase tracking-wider">{label}</Typography>
|
||||
<Typography className="mt-2 text-2xl font-semibold">{value}</Typography>
|
||||
<Typography variant="secondary" className="mt-1 text-sm">{detail}</Typography>
|
||||
</DesignCard>
|
||||
);
|
||||
}
|
||||
|
||||
function parseLifecycleFilter(value: string): CommsContact["lifecycle"] | "all" {
|
||||
if (value === "all" || value === "Lead" || value === "Customer" || value === "Champion" || value === "At risk") {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unknown lifecycle filter ${value}`);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Contacts",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
@ -27,6 +27,7 @@ import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CaretRightIcon,
|
||||
ChatCircleDotsIcon,
|
||||
DatabaseIcon,
|
||||
ChartBarIcon,
|
||||
CubeIcon,
|
||||
@ -39,7 +40,7 @@ import {
|
||||
type Icon as PhosphorIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { ALL_APPS, type AppId } from "@hexclave/shared/dist/apps/apps-config";
|
||||
import { ALL_APPS, type AppId } from "@/lib/shared-apps-config";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useAdminApp, useProjectId } from "./use-admin-app";
|
||||
@ -101,6 +102,22 @@ const usersItem: Item = {
|
||||
type: "item",
|
||||
};
|
||||
|
||||
const contactsItem: Item = {
|
||||
name: "Contacts",
|
||||
href: "/contacts",
|
||||
regex: /^\/projects\/[^\/]+\/contacts(\/.*)?$/,
|
||||
icon: UsersIcon,
|
||||
type: "item",
|
||||
};
|
||||
|
||||
const commsItem: Item = {
|
||||
name: "Comms",
|
||||
href: "/comms",
|
||||
regex: /^\/projects\/[^\/]+\/comms(\/.*)?$/,
|
||||
icon: ChatCircleDotsIcon,
|
||||
type: "item",
|
||||
};
|
||||
|
||||
const dashboardsItem: Item = {
|
||||
name: "Dashboards",
|
||||
href: "/dashboards",
|
||||
@ -560,6 +577,18 @@ function SidebarContent({
|
||||
href={`/projects/${projectId}${usersItem.href}`}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<NavItem
|
||||
item={contactsItem}
|
||||
onClick={onNavigate}
|
||||
href={`/projects/${projectId}${contactsItem.href}`}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<NavItem
|
||||
item={commsItem}
|
||||
onClick={onNavigate}
|
||||
href={`/projects/${projectId}${commsItem.href}`}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<NavItem
|
||||
item={dashboardsItem}
|
||||
onClick={onNavigate}
|
||||
|
||||
@ -5,7 +5,7 @@ import { ALL_APPS_FRONTEND, AppFrontend, getAppPath, getDocumentationHref } from
|
||||
import { isAppEnabled } from "@/lib/apps-utils";
|
||||
import { useUpdateConfig } from "@/components/config-update";
|
||||
import { CheckIcon, DotsThreeVerticalIcon } from "@phosphor-icons/react";
|
||||
import { ALL_APPS, AppId, getParentAppId } from "@hexclave/shared/dist/apps/apps-config";
|
||||
import { ALL_APPS, type AppId, getParentAppId } from "@/lib/shared-apps-config";
|
||||
import { appSquarePaddingExpression, appSquareWidthExpression, AppIcon as SharedAppIcon } from "@hexclave/shared/dist/apps/apps-ui";
|
||||
import { useState } from "react";
|
||||
import { AppWarningModal } from "./app-warning-modal";
|
||||
|
||||
@ -2,7 +2,7 @@ import type { JSX } from "react";
|
||||
import { Link } from "@/components/link";
|
||||
import { ChartLineIcon, ChatCircleDotsIcon, ClipboardTextIcon, CodeIcon, CreditCardIcon, CursorClickIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, MonitorPlayIcon, RocketIcon, ShieldCheckIcon, SparkleIcon, TelevisionSimpleIcon, TerminalWindowIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react";
|
||||
import { StackAdminApp } from "@hexclave/next";
|
||||
import type { AppId } from "@hexclave/shared/dist/apps/apps-config";
|
||||
import type { AppId } from "@/lib/shared-apps-config";
|
||||
import { getRelativePart, isChildUrl } from "@hexclave/shared/dist/utils/urls";
|
||||
import Image, { StaticImageData } from "next/image";
|
||||
import ConvexLogo from "../../public/convex-logo.png";
|
||||
@ -259,6 +259,27 @@ export const ALL_APPS_FRONTEND = {
|
||||
</>
|
||||
),
|
||||
},
|
||||
comms: {
|
||||
icon: createSvgIcon(() => <>
|
||||
<path d="M4 5.5 A2.5 2.5 0 0 1 6.5 3 L17.5 3 A2.5 2.5 0 0 1 20 5.5 L20 13.5 A2.5 2.5 0 0 1 17.5 16 L9 16 L4 20 L5.2 15.4 A2.5 2.5 0 0 1 4 13.3 Z" />
|
||||
<path d="M8 8 L16 8" />
|
||||
<path d="M8 11 L13.5 11" />
|
||||
</>),
|
||||
href: "comms",
|
||||
navigationItems: [
|
||||
{ displayName: "Message Stream", href: "." },
|
||||
{ displayName: "Drafts", href: "./drafts" },
|
||||
{ displayName: "Topics", href: "./topics" },
|
||||
],
|
||||
screenshots: [],
|
||||
storeDescription: (
|
||||
<>
|
||||
<p>Comms is a unified communication primitive for customer-facing teams.</p>
|
||||
<p>Route email, shared Slack, Discord, support tickets, push notifications, and future channels through one operational surface.</p>
|
||||
<p>AI-assisted topics keep related messages together while operators can override, merge, and split anything manually.</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
"data-vault": {
|
||||
icon: VaultIcon,
|
||||
href: "data-vault",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ALL_APPS_FRONTEND, hasNavigationItems } from "@/lib/apps-frontend";
|
||||
import { ALL_APPS, getParentAppId, type AppId } from "@hexclave/shared/dist/apps/apps-config";
|
||||
import { ALL_APPS, getParentAppId, type AppId } from "@/lib/shared-apps-config";
|
||||
|
||||
type InstalledAppConfig = {
|
||||
enabled?: boolean,
|
||||
|
||||
495
apps/dashboard/src/lib/comms-mock.ts
Normal file
495
apps/dashboard/src/lib/comms-mock.ts
Normal file
@ -0,0 +1,495 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type CommsPlatform = "email" | "slack" | "discord" | "support" | "push";
|
||||
export type CommsDirection = "inbound" | "outbound";
|
||||
|
||||
export type CommsContact = {
|
||||
id: string,
|
||||
name: string,
|
||||
role: string,
|
||||
company: string,
|
||||
avatarUrl: string,
|
||||
lifecycle: "Lead" | "Customer" | "Champion" | "At risk",
|
||||
owner: string,
|
||||
score: number,
|
||||
userId: string | null,
|
||||
source: string,
|
||||
tags: string[],
|
||||
lastTouchedAt: string,
|
||||
channels: {
|
||||
emails: string[],
|
||||
slackIds: string[],
|
||||
discordIds: string[],
|
||||
supportIds: string[],
|
||||
pushTokens: string[],
|
||||
},
|
||||
notes: string[],
|
||||
};
|
||||
|
||||
export type CommsMessage = {
|
||||
id: string,
|
||||
platform: CommsPlatform,
|
||||
direction: CommsDirection,
|
||||
contactId: string,
|
||||
topicId: string,
|
||||
timestamp: string,
|
||||
channelLabel: string,
|
||||
subject?: string,
|
||||
htmlBody?: string,
|
||||
text: string,
|
||||
replyToId?: string,
|
||||
status: "new" | "triaged" | "sent" | "queued" | "failed",
|
||||
urgency: "low" | "normal" | "high",
|
||||
attachments: string[],
|
||||
aiReason: string,
|
||||
};
|
||||
|
||||
export type CommsTopic = {
|
||||
id: string,
|
||||
title: string,
|
||||
summary: string,
|
||||
status: "open" | "waiting" | "resolved",
|
||||
confidence: number,
|
||||
owner: string,
|
||||
contactIds: string[],
|
||||
messageIds: string[],
|
||||
labels: string[],
|
||||
};
|
||||
|
||||
export type CommsDraft = {
|
||||
id: string,
|
||||
platform: CommsPlatform,
|
||||
title: string,
|
||||
contactId: string,
|
||||
recipients: string[],
|
||||
channelLabel: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
updatedAt: string,
|
||||
status: "draft" | "scheduled",
|
||||
};
|
||||
|
||||
export const COMMS_PLATFORM_LABELS = {
|
||||
email: "Email",
|
||||
slack: "Shared Slack",
|
||||
discord: "Discord",
|
||||
support: "Support ticket",
|
||||
push: "Push",
|
||||
} as const satisfies Record<CommsPlatform, string>;
|
||||
|
||||
export const COMMS_PLATFORM_OPTIONS = Object.entries(COMMS_PLATFORM_LABELS).map(([value, label]) => ({ value, label }));
|
||||
|
||||
export const COMMS_CONTACTS = [
|
||||
{
|
||||
id: "contact-maya",
|
||||
name: "Maya Chen",
|
||||
role: "Head of Support",
|
||||
company: "Nimbus Labs",
|
||||
avatarUrl: "https://api.dicebear.com/8.x/initials/svg?seed=Maya%20Chen",
|
||||
lifecycle: "Champion",
|
||||
owner: "Priya S.",
|
||||
score: 92,
|
||||
userId: "user_8f21",
|
||||
source: "Product signup",
|
||||
tags: ["enterprise", "workspace-admin", "beta"],
|
||||
lastTouchedAt: "2026-07-08T20:45:00.000Z",
|
||||
channels: {
|
||||
emails: ["maya@nimbus.example", "m.chen@personal.example"],
|
||||
slackIds: ["U04NIMBUS7", "U04NIMBUS9"],
|
||||
discordIds: ["maya.chen#1824"],
|
||||
supportIds: ["zendesk:49120"],
|
||||
pushTokens: ["ios:prod:maya-primary"],
|
||||
},
|
||||
notes: [
|
||||
"Primary buyer for support workflow consolidation.",
|
||||
"Prefers Slack for urgent incidents and email for weekly summaries.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "contact-omar",
|
||||
name: "Omar Haddad",
|
||||
role: "Solutions Engineer",
|
||||
company: "Aster Cloud",
|
||||
avatarUrl: "https://api.dicebear.com/8.x/initials/svg?seed=Omar%20Haddad",
|
||||
lifecycle: "Customer",
|
||||
owner: "Elena R.",
|
||||
score: 78,
|
||||
userId: "user_4bd2",
|
||||
source: "Shared Slack",
|
||||
tags: ["technical", "api", "renewal"],
|
||||
lastTouchedAt: "2026-07-08T19:20:00.000Z",
|
||||
channels: {
|
||||
emails: ["omar@aster.example"],
|
||||
slackIds: ["U02ASTER1"],
|
||||
discordIds: [],
|
||||
supportIds: ["intercom:conv-883"],
|
||||
pushTokens: ["webpush:omar-laptop", "webpush:omar-desktop"],
|
||||
},
|
||||
notes: [
|
||||
"Usually sends implementation questions without threading them.",
|
||||
"Account has renewal conversation active this month.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "contact-lina",
|
||||
name: "Lina Park",
|
||||
role: "Community Lead",
|
||||
company: "Forge Guild",
|
||||
avatarUrl: "https://api.dicebear.com/8.x/initials/svg?seed=Lina%20Park",
|
||||
lifecycle: "Lead",
|
||||
owner: "Mateo G.",
|
||||
score: 64,
|
||||
userId: null,
|
||||
source: "Discord community",
|
||||
tags: ["discord", "community", "prospect"],
|
||||
lastTouchedAt: "2026-07-08T18:10:00.000Z",
|
||||
channels: {
|
||||
emails: [],
|
||||
slackIds: [],
|
||||
discordIds: ["lina.guild#7744", "forge-lina#1190"],
|
||||
supportIds: [],
|
||||
pushTokens: [],
|
||||
},
|
||||
notes: [
|
||||
"No product account yet; only known through Discord.",
|
||||
"Interested in importing moderation context before launch.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "contact-sam",
|
||||
name: "Sam Rivera",
|
||||
role: "Growth PM",
|
||||
company: "BrightCart",
|
||||
avatarUrl: "https://api.dicebear.com/8.x/initials/svg?seed=Sam%20Rivera",
|
||||
lifecycle: "At risk",
|
||||
owner: "Hannah K.",
|
||||
score: 47,
|
||||
userId: "user_93aa",
|
||||
source: "Support ticket",
|
||||
tags: ["billing", "mobile", "escalated"],
|
||||
lastTouchedAt: "2026-07-08T16:35:00.000Z",
|
||||
channels: {
|
||||
emails: ["sam@brightcart.example"],
|
||||
slackIds: ["U09BRIGHT2"],
|
||||
discordIds: [],
|
||||
supportIds: ["linear:TICK-9021", "zendesk:50712"],
|
||||
pushTokens: ["android:brightcart-sam"],
|
||||
},
|
||||
notes: [
|
||||
"Needs careful follow-up after mobile push delay.",
|
||||
"Billing admin, but most support requests come through tickets.",
|
||||
],
|
||||
},
|
||||
] as const satisfies CommsContact[];
|
||||
|
||||
export const INITIAL_COMMS_MESSAGES = [
|
||||
{
|
||||
id: "msg-1001",
|
||||
platform: "email",
|
||||
direction: "inbound",
|
||||
contactId: "contact-maya",
|
||||
topicId: "topic-routing",
|
||||
timestamp: "2026-07-08T20:38:00.000Z",
|
||||
channelLabel: "maya@nimbus.example",
|
||||
subject: "Routing support replies by workspace",
|
||||
htmlBody: "<p>Can Comms route replies to the workspace owner when the original message came from a child account?</p>",
|
||||
text: "Can Comms route replies to the workspace owner when the original message came from a child account?",
|
||||
status: "new",
|
||||
urgency: "high",
|
||||
attachments: ["workspace-routing.csv"],
|
||||
aiReason: "Matched to the routing topic because the message asks about ownership and reply routing.",
|
||||
},
|
||||
{
|
||||
id: "msg-1002",
|
||||
platform: "slack",
|
||||
direction: "inbound",
|
||||
contactId: "contact-omar",
|
||||
topicId: "topic-api",
|
||||
timestamp: "2026-07-08T20:26:00.000Z",
|
||||
channelLabel: "#aster-shared / unthreaded",
|
||||
text: "Does the webhook payload include the topic id or do we need to call back into the API?",
|
||||
status: "triaged",
|
||||
urgency: "normal",
|
||||
attachments: [],
|
||||
aiReason: "Classified as API integration because it mentions webhook payloads and topic IDs.",
|
||||
},
|
||||
{
|
||||
id: "msg-1003",
|
||||
platform: "discord",
|
||||
direction: "inbound",
|
||||
contactId: "contact-lina",
|
||||
topicId: "topic-community",
|
||||
timestamp: "2026-07-08T20:12:00.000Z",
|
||||
channelLabel: "Forge Guild / #launch-planning",
|
||||
text: "If someone joins Discord before signing up, can we merge them into the same contact later?",
|
||||
status: "new",
|
||||
urgency: "normal",
|
||||
attachments: [],
|
||||
aiReason: "Assigned to community launch because the contact is Discord-only and asks about later merge behavior.",
|
||||
},
|
||||
{
|
||||
id: "msg-1004",
|
||||
platform: "support",
|
||||
direction: "inbound",
|
||||
contactId: "contact-sam",
|
||||
topicId: "topic-mobile-push",
|
||||
timestamp: "2026-07-08T19:58:00.000Z",
|
||||
channelLabel: "TICK-9021",
|
||||
subject: "Push notification delivery delay",
|
||||
text: "Android users are reporting a 10 minute delay on campaign push notifications.",
|
||||
status: "new",
|
||||
urgency: "high",
|
||||
attachments: ["ticket-log.txt"],
|
||||
aiReason: "Matched to mobile push because of Android delivery delay wording.",
|
||||
},
|
||||
{
|
||||
id: "msg-1005",
|
||||
platform: "email",
|
||||
direction: "outbound",
|
||||
contactId: "contact-maya",
|
||||
topicId: "topic-routing",
|
||||
timestamp: "2026-07-08T19:45:00.000Z",
|
||||
channelLabel: "maya@nimbus.example",
|
||||
subject: "Re: Routing support replies by workspace",
|
||||
htmlBody: "<p>Yes, we can model the owner as a contact-level rule and keep the original user's context attached.</p>",
|
||||
text: "Yes, we can model the owner as a contact-level rule and keep the original user's context attached.",
|
||||
status: "sent",
|
||||
urgency: "normal",
|
||||
attachments: [],
|
||||
aiReason: "Outbound reply continued the existing routing topic.",
|
||||
},
|
||||
{
|
||||
id: "msg-1006",
|
||||
platform: "push",
|
||||
direction: "outbound",
|
||||
contactId: "contact-sam",
|
||||
topicId: "topic-mobile-push",
|
||||
timestamp: "2026-07-08T19:30:00.000Z",
|
||||
channelLabel: "Android production",
|
||||
subject: "Delivery incident update",
|
||||
text: "We are investigating delayed campaign push notifications and will update you in the ticket.",
|
||||
status: "sent",
|
||||
urgency: "normal",
|
||||
attachments: [],
|
||||
aiReason: "Outbound push was associated with the active mobile delivery incident.",
|
||||
},
|
||||
] as const satisfies CommsMessage[];
|
||||
|
||||
export const COMMS_TOPICS = [
|
||||
{
|
||||
id: "topic-routing",
|
||||
title: "Workspace reply routing",
|
||||
summary: "Nimbus wants replies to route through the right workspace owner while preserving child-account context.",
|
||||
status: "open",
|
||||
confidence: 94,
|
||||
owner: "Priya S.",
|
||||
contactIds: ["contact-maya"],
|
||||
messageIds: ["msg-1001", "msg-1005"],
|
||||
labels: ["routing", "enterprise"],
|
||||
},
|
||||
{
|
||||
id: "topic-api",
|
||||
title: "Webhook and API surfaces",
|
||||
summary: "Aster is validating which Comms identifiers appear in outbound webhook payloads.",
|
||||
status: "waiting",
|
||||
confidence: 86,
|
||||
owner: "Elena R.",
|
||||
contactIds: ["contact-omar"],
|
||||
messageIds: ["msg-1002"],
|
||||
labels: ["api", "webhooks"],
|
||||
},
|
||||
{
|
||||
id: "topic-community",
|
||||
title: "Discord-led launch planning",
|
||||
summary: "Forge Guild is testing Discord-first contact capture before product signup.",
|
||||
status: "open",
|
||||
confidence: 78,
|
||||
owner: "Mateo G.",
|
||||
contactIds: ["contact-lina"],
|
||||
messageIds: ["msg-1003"],
|
||||
labels: ["discord", "contacts"],
|
||||
},
|
||||
{
|
||||
id: "topic-mobile-push",
|
||||
title: "Mobile push delay",
|
||||
summary: "BrightCart escalated Android push delivery latency and needs proactive updates.",
|
||||
status: "open",
|
||||
confidence: 91,
|
||||
owner: "Hannah K.",
|
||||
contactIds: ["contact-sam"],
|
||||
messageIds: ["msg-1004", "msg-1006"],
|
||||
labels: ["push", "incident"],
|
||||
},
|
||||
] as const satisfies CommsTopic[];
|
||||
|
||||
export const COMMS_DRAFTS = [
|
||||
{
|
||||
id: "draft-1",
|
||||
platform: "email",
|
||||
title: "Workspace routing follow-up",
|
||||
contactId: "contact-maya",
|
||||
recipients: ["maya@nimbus.example"],
|
||||
channelLabel: "maya@nimbus.example",
|
||||
subject: "Workspace routing design",
|
||||
body: "We can preserve the original user context while routing the reply to the workspace owner.",
|
||||
updatedAt: "2026-07-08T20:50:00.000Z",
|
||||
status: "draft",
|
||||
},
|
||||
{
|
||||
id: "draft-2",
|
||||
platform: "discord",
|
||||
title: "Discord identity merge notes",
|
||||
contactId: "contact-lina",
|
||||
recipients: ["lina.guild#7744"],
|
||||
channelLabel: "Forge Guild / #launch-planning",
|
||||
subject: "",
|
||||
body: "Yes, Discord-only contacts can later be merged with signup users while retaining both channel identities.",
|
||||
updatedAt: "2026-07-08T20:18:00.000Z",
|
||||
status: "scheduled",
|
||||
},
|
||||
] as const satisfies CommsDraft[];
|
||||
|
||||
type IncomingTemplate = {
|
||||
platform: CommsPlatform,
|
||||
contactId: string,
|
||||
topicId: string,
|
||||
channelLabel: string,
|
||||
subject?: string,
|
||||
text: string,
|
||||
urgency: CommsMessage["urgency"],
|
||||
attachments?: string[],
|
||||
aiReason: string,
|
||||
};
|
||||
|
||||
const incomingTemplates: IncomingTemplate[] = [
|
||||
{
|
||||
platform: "slack",
|
||||
contactId: "contact-omar",
|
||||
topicId: "topic-api",
|
||||
channelLabel: "#aster-shared / #implementation",
|
||||
text: "Tiny follow-up: can we subscribe to topic merge events separately from normal message events?",
|
||||
urgency: "normal",
|
||||
aiReason: "The unthreaded Slack question mentions topic merge events, so it remains in API surfaces.",
|
||||
},
|
||||
{
|
||||
platform: "email",
|
||||
contactId: "contact-maya",
|
||||
topicId: "topic-routing",
|
||||
channelLabel: "m.chen@personal.example",
|
||||
subject: "One more routing edge case",
|
||||
text: "What happens if two contacts are merged after a reply route has already been learned?",
|
||||
urgency: "high",
|
||||
attachments: ["routing-edge-case.png"],
|
||||
aiReason: "The message asks about merged contacts affecting routing, which belongs with workspace reply routing.",
|
||||
},
|
||||
{
|
||||
platform: "discord",
|
||||
contactId: "contact-lina",
|
||||
topicId: "topic-community",
|
||||
channelLabel: "Forge Guild / #moderator-chat",
|
||||
text: "Moderators are asking whether imported Discord IDs can have multiple aliases on the same contact.",
|
||||
urgency: "normal",
|
||||
aiReason: "Discord identity aliases are part of the Discord-led launch planning topic.",
|
||||
},
|
||||
{
|
||||
platform: "support",
|
||||
contactId: "contact-sam",
|
||||
topicId: "topic-mobile-push",
|
||||
channelLabel: "TICK-9021",
|
||||
subject: "New affected cohort",
|
||||
text: "We found that the delay only affects users who opted into promotional push notifications.",
|
||||
urgency: "high",
|
||||
attachments: ["affected-cohort.csv"],
|
||||
aiReason: "The ticket adds a cohort detail to the existing mobile push delivery incident.",
|
||||
},
|
||||
];
|
||||
|
||||
let liveMessages: CommsMessage[] = [...INITIAL_COMMS_MESSAGES];
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
let generatedCount = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function getRandomTemplate(): IncomingTemplate {
|
||||
const template = incomingTemplates[Math.floor(Math.random() * incomingTemplates.length)];
|
||||
if (!template) {
|
||||
throw new Error("Expected at least one Comms incoming template.");
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
function createGeneratedMessage(): CommsMessage {
|
||||
generatedCount += 1;
|
||||
const template = getRandomTemplate();
|
||||
const textSuffixes = ["", " (new live inbound)", " — adding this before the next sync.", " Can someone confirm?"];
|
||||
const suffix = textSuffixes[Math.floor(Math.random() * textSuffixes.length)] ?? "";
|
||||
return {
|
||||
id: `msg-live-${generatedCount}`,
|
||||
platform: template.platform,
|
||||
direction: "inbound",
|
||||
contactId: template.contactId,
|
||||
topicId: template.topicId,
|
||||
timestamp: new Date().toISOString(),
|
||||
channelLabel: template.channelLabel,
|
||||
subject: template.subject,
|
||||
htmlBody: template.platform === "email" ? `<p>${template.text}${suffix}</p>` : undefined,
|
||||
text: `${template.text}${suffix}`,
|
||||
status: "new",
|
||||
urgency: template.urgency,
|
||||
attachments: template.attachments ?? [],
|
||||
aiReason: template.aiReason,
|
||||
};
|
||||
}
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLiveFeedStarted() {
|
||||
if (intervalId !== null || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
intervalId = setInterval(() => {
|
||||
liveMessages = [createGeneratedMessage(), ...liveMessages].slice(0, 24);
|
||||
emit();
|
||||
}, 3_500);
|
||||
}
|
||||
|
||||
function subscribeToCommsMessages(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
ensureLiveFeedStarted();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0 && intervalId !== null) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getCommsMessagesSnapshot() {
|
||||
return liveMessages;
|
||||
}
|
||||
|
||||
export function useCommsMessages() {
|
||||
return useSyncExternalStore(subscribeToCommsMessages, getCommsMessagesSnapshot, getCommsMessagesSnapshot);
|
||||
}
|
||||
|
||||
export function getCommsContact(contactId: string): CommsContact | null {
|
||||
return COMMS_CONTACTS.find((contact) => contact.id === contactId) ?? null;
|
||||
}
|
||||
|
||||
export function getCommsTopic(topicId: string): CommsTopic | null {
|
||||
return COMMS_TOPICS.find((topic) => topic.id === topicId) ?? null;
|
||||
}
|
||||
|
||||
export function formatCommsTimestamp(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "Invalid date";
|
||||
}
|
||||
return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
6
apps/dashboard/src/lib/shared-apps-config.ts
Normal file
6
apps/dashboard/src/lib/shared-apps-config.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export {
|
||||
ALL_APPS,
|
||||
ALL_APP_TAGS,
|
||||
getParentAppId,
|
||||
} from "../../../../packages/shared/src/apps/apps-config";
|
||||
export type { AppId } from "../../../../packages/shared/src/apps/apps-config";
|
||||
@ -50,7 +50,7 @@
|
||||
"dev:tui": "pnpm pre && (trap 'kill 0' EXIT; pnpm run build-packages:watch & pnpm run generate-sdks:watch & pnpm run generate-openapi-docs:watch & pnpm run generate-setup-prompt-docs:watch & turbo run dev --ui tui --concurrency 99999 --filter=./apps/* --filter=@hexclave/docs-mintlify --filter=./packages/* --filter=./examples/demo --filter=./examples/tanstack-start-demo)",
|
||||
"dev:inspect": "pnpm pre && STACK_BACKEND_DEV_EXTRA_ARGS=\"--inspect\" pnpm run dev",
|
||||
"dev:profile": "pnpm pre && STACK_BACKEND_DEV_EXTRA_ARGS=\"--experimental-cpu-prof\" pnpm run dev",
|
||||
"dev:basic": "pnpm pre && concurrently -k \"pnpm run build-packages:watch\" \"pnpm run generate-sdks:watch\" \"turbo run dev --concurrency 99999 --filter=@hexclave/backend --filter=@hexclave/bulldozer-js --filter=@hexclave/mcp --filter=@hexclave/dashboard --filter=@hexclave/mock-oauth-server\"",
|
||||
"dev:basic": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"turbo run dev --concurrency 99999 --filter=@hexclave/dashboard --filter=@hexclave/backend --filter=@hexclave/bulldozer-js --filter=@hexclave/mock-oauth-server\"",
|
||||
"dev:docs": "pnpm pre && concurrently -k \"pnpm run generate-setup-prompt-docs:watch\" \"turbo run dev --concurrency 99999 --filter=@hexclave/docs-mintlify\"",
|
||||
"dev:named": "pnpm pre && concurrently -k \"pnpm run dev\" \"node -e \\\"process.title='node (stack-named-dev-server)'; process.stdin.resume();\\\"\"",
|
||||
"kill-dev:named": "(pgrep -f 'stack-named-dev-server' | xargs -r -n1 pkill -P); echo 'Killed named dev server (if found). Sleeping to give some time for it to shut down...' && sleep 10",
|
||||
|
||||
@ -112,6 +112,12 @@ export const ALL_APPS = {
|
||||
tags: ["comms", "developers", "expert"],
|
||||
stage: "alpha",
|
||||
},
|
||||
"comms": {
|
||||
displayName: "Comms",
|
||||
subtitle: "Unified customer communication across channels",
|
||||
tags: ["comms", "operations", "gtm"],
|
||||
stage: "alpha",
|
||||
},
|
||||
"data-vault": {
|
||||
displayName: "Data Vault",
|
||||
subtitle: "Secure storage for sensitive user data",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user