Comms app offsite presentation

This commit is contained in:
Konstantin Wohlwend 2026-07-08 17:22:13 -07:00
parent 38237fe363
commit 52fe2d2207
21 changed files with 1872 additions and 16 deletions

View File

@ -1,16 +1,6 @@
{
"version": 1,
"hooks": {
"afterFileEdit": [
{
"command": "./hooks/lint-file.sh"
}
],
"stop": [
{
"command": "./hooks/stop-check.sh"
}
]
}
}

View File

@ -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";

View File

@ -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.";
}

View File

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

View File

@ -0,0 +1,9 @@
import PageClient from "./page-client";
export const metadata = {
title: "Comms Drafts",
};
export default function Page() {
return <PageClient />;
}

View File

@ -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}`);
}

View File

@ -0,0 +1,9 @@
import PageClient from "./page-client";
export const metadata = {
title: "Comms Message Stream",
};
export default function Page() {
return <PageClient />;
}

View File

@ -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();
}

View File

@ -0,0 +1,9 @@
import PageClient from "./page-client";
export const metadata = {
title: "Comms Topics",
};
export default function Page() {
return <PageClient />;
}

View File

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

View File

@ -0,0 +1,9 @@
import PageClient from "./page-client";
export const metadata = {
title: "Contact Details",
};
export default function Page() {
return <PageClient />;
}

View File

@ -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}`);
}

View File

@ -0,0 +1,9 @@
import PageClient from "./page-client";
export const metadata = {
title: "Contacts",
};
export default function Page() {
return <PageClient />;
}

View File

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

View File

@ -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";

View File

@ -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",

View File

@ -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,

View 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" });
}

View 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";

View File

@ -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",

View File

@ -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",