Comms app
Some checks failed
DB migration compat / Check if migrations changed (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled

This commit is contained in:
Konstantin Wohlwend 2026-07-09 19:03:20 -07:00
parent d759bfbac4
commit 9c9261a8bf
7 changed files with 82 additions and 39 deletions

View File

@ -531,7 +531,7 @@ function getDefaultChannelLabel(platform: CommsPlatform, contact: CommsContact):
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.";
return "Email supports recipients, a subject, attachments, and a richer body.";
}
if (platform === "discord") {
return "Discord replies require a server/channel/thread or an incoming Discord message to anchor the response.";

View File

@ -29,7 +29,7 @@ export default function PageClient() {
submitLabel="Save draft"
onSubmit={(value) => {
const nextDraft: CommsDraft = {
id: `draft-mock-${drafts.length + 1}`,
id: `draft-${drafts.length + 1}`,
platform: value.platform,
title: value.subject.trim() || `${COMMS_PLATFORM_LABELS[value.platform]} message`,
contactId: value.contactId,
@ -41,7 +41,7 @@ export default function PageClient() {
status: "draft",
};
setDrafts((current) => [nextDraft, ...current]);
setNotice(`Saved ${COMMS_PLATFORM_LABELS[value.platform]} draft for the presentation mock.`);
setNotice(`Saved ${COMMS_PLATFORM_LABELS[value.platform]} draft.`);
}}
/>
</DesignCard>
@ -54,7 +54,7 @@ export default function PageClient() {
<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.
Drafts stay organized by channel so teams can review and send from the right platform context.
</Typography>
</div>
</div>

View File

@ -57,7 +57,7 @@ export default function PageClient() {
fillWidth
actions={
<div className="flex flex-wrap items-center gap-2">
<DesignBadge label="Live mock feed" color="green" size="sm" />
<DesignBadge label="Live feed" color="green" size="sm" />
<DesignButton variant="outline" size="sm">
<ArrowsClockwiseIcon className="h-4 w-4" />
Refresh view
@ -73,7 +73,7 @@ export default function PageClient() {
</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.
New inbound messages appear every few seconds.
</Typography>
</div>
<div className="flex flex-wrap items-center gap-2">

View File

@ -88,14 +88,14 @@ export default function PageClient() {
onMerge={() => {
const otherTopic = topics.find((topic) => topic.id !== selectedTopic.id);
if (otherTopic == null) {
setNotice("No other mock topic is available to merge.");
setNotice("No other topic is available to merge.");
return;
}
setTopics((current) => current.filter((topic) => topic.id !== otherTopic.id));
setNotice(`Mock merged "${otherTopic.title}" into "${selectedTopic.title}".`);
setNotice(`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.`);
setNotice(`Split prepared for "${selectedTopic.title}". Selected messages will move into a new topic.`);
}}
/>
<DesignButton variant="outline" size="sm">
@ -116,10 +116,10 @@ export default function PageClient() {
<Typography className="mb-3 text-sm font-semibold">Respond in this topic</Typography>
<CommsComposer
compact
submitLabel="Send mock reply"
submitLabel="Send reply"
initialContactId={selectedTopic.contactIds[0]}
onSubmit={(value) => {
setNotice(`Mock ${value.platform} reply composed for "${selectedTopic.title}".`);
setNotice(`${value.platform} reply composed for "${selectedTopic.title}".`);
}}
/>
</div>

View File

@ -45,7 +45,7 @@ export default function Page() {
</p>
</div>
<div className="rounded-full bg-green-500/10 px-3 py-1 text-xs font-medium text-green-700 dark:text-green-300">
Presentation mock
Active categorization
</div>
</div>
@ -110,8 +110,8 @@ export default function Page() {
<option>Discord</option>
<option>Support ticket</option>
</select>
<textarea className="min-h-9 rounded-lg border border-border bg-background px-3 py-2 text-sm" placeholder="Compose a mock response in this topic..." />
<button className="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground" type="button">Send mock reply</button>
<textarea className="min-h-9 rounded-lg border border-border bg-background px-3 py-2 text-sm" placeholder="Compose a response in this topic..." />
<button className="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground" type="button">Send reply</button>
</div>
</div>
</div>

View File

@ -27,7 +27,7 @@ export default function PageClient() {
return (
<AppEnabledGuard appId="authentication">
<PageLayout title="Contact not found">
<DesignAlert variant="error" description="This mock contact does not exist." />
<DesignAlert variant="error" description="This contact does not exist." />
</PageLayout>
</AppEnabledGuard>
);
@ -105,14 +105,14 @@ export default function PageClient() {
<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}.`)} />
<MessageRow key={message.id} message={message} selected={false} onSelect={() => setNotice(`Selected 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>
<DesignCard title="Merge contacts" subtitle="Manual identity resolution for duplicate CRM/channel records." icon={GitMergeIcon} gradient="default" glassmorphic>
<div className="space-y-4">
<DesignSelectorDropdown
value={mergeTargetId}
@ -134,11 +134,11 @@ export default function PageClient() {
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.`);
setNotice(`Merged ${mergeTarget.name} into ${contact.name}. Channel fields were combined.`);
}}
>
<GitMergeIcon className="h-4 w-4" />
Merge in mock
Merge contacts
</DesignButton>
</div>
</DesignCard>

View File

@ -1,6 +1,7 @@
"use client";
import { DesignBadge, DesignCard, DesignInput, DesignSelectorDropdown } from "@/components/design-components";
import { Link } from "@/components/link";
import { DesignBadge, 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";
@ -8,7 +9,7 @@ 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";
import { ContactAvatar } from "../comms/comms-components";
const lifecycleOptions = [
{ value: "all", label: "All lifecycles" },
@ -52,18 +53,18 @@ export default function PageClient() {
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 className="mb-4 grid gap-4 md:grid-cols-3">
<MetricBlock label="Contacts" value={COMMS_CONTACTS.length.toString()} detail="CRM identities" />
<MetricBlock label="User-backed" value={userBackedCount.toString()} detail="Automatically also contacts" />
<MetricBlock 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" />
<div className="flex flex-col overflow-hidden rounded-xl border border-border bg-background">
<div className="flex flex-col gap-3 border-b border-border/70 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-2 min-w-0">
<AddressBookIcon className="h-4 w-4 text-muted-foreground" />
<Typography className="text-sm font-semibold">Contact directory</Typography>
<DesignBadge label={`${contacts.length} shown`} color="blue" size="sm" />
<Typography variant="secondary" className="text-xs">{contacts.length} shown</Typography>
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(220px,1fr)_180px]">
<div className="relative">
@ -72,39 +73,81 @@ export default function PageClient() {
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search contacts"
className="pl-9"
className="h-8 pl-9 text-sm"
/>
</div>
<DesignSelectorDropdown
value={lifecycle}
options={lifecycleOptions}
onValueChange={(value) => setLifecycle(parseLifecycleFilter(value))}
size="md"
size="sm"
/>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="grid grid-cols-[minmax(16rem,1.5fr)_8rem_12rem_10rem_8rem] gap-3 border-b border-border/70 bg-muted/30 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<div>Contact</div>
<div>Status</div>
<div>Channels</div>
<div>Owner</div>
<div className="text-right">Last touched</div>
</div>
<div className="divide-y divide-border/70">
{contacts.map((contact) => (
<ContactMiniCard key={contact.id} contact={contact} projectId={projectId} />
<ContactRow key={contact.id} contact={contact} projectId={projectId} />
))}
</div>
</DesignCard>
</div>
</PageLayout>
</AppEnabledGuard>
);
}
function MetricCard({ label, value, detail }: { label: string, value: string, detail: string }) {
function MetricBlock({ label, value, detail }: { label: string, value: string, detail: string }) {
return (
<DesignCard glassmorphic contentClassName="p-5">
<div className="rounded-xl border border-border bg-background px-4 py-3">
<Typography variant="secondary" className="text-xs font-semibold uppercase tracking-wider">{label}</Typography>
<Typography className="mt-2 text-2xl font-semibold">{value}</Typography>
<Typography className="mt-1 text-2xl font-semibold">{value}</Typography>
<Typography variant="secondary" className="mt-1 text-sm">{detail}</Typography>
</DesignCard>
</div>
);
}
function ContactRow({ contact, projectId }: { contact: CommsContact, projectId: string }) {
const channels = [
contact.channels.emails.length > 0 ? `${contact.channels.emails.length} email${contact.channels.emails.length === 1 ? "" : "s"}` : null,
contact.channels.slackIds.length > 0 ? `${contact.channels.slackIds.length} Slack` : null,
contact.channels.discordIds.length > 0 ? `${contact.channels.discordIds.length} Discord` : null,
contact.channels.supportIds.length > 0 ? `${contact.channels.supportIds.length} ticket${contact.channels.supportIds.length === 1 ? "" : "s"}` : null,
].filter((value) => value != null);
return (
<Link
href={`/projects/${encodeURIComponent(projectId)}/contacts/${encodeURIComponent(contact.id)}`}
className="grid grid-cols-[minmax(16rem,1.5fr)_8rem_12rem_10rem_8rem] items-center gap-3 px-4 py-2.5 text-sm transition-colors duration-150 hover:bg-muted/50 hover:transition-none"
>
<div className="flex min-w-0 items-center gap-3">
<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-xs">{contact.role} · {contact.company}</Typography>
</div>
</div>
<div>
<DesignBadge label={contact.lifecycle} color={contact.lifecycle === "At risk" ? "orange" : contact.lifecycle === "Champion" ? "green" : "blue"} size="sm" />
</div>
<Typography variant="secondary" className="truncate text-xs">{channels.join(", ") || "No channels"}</Typography>
<Typography className="truncate text-xs">{contact.owner}</Typography>
<Typography variant="secondary" className="truncate text-right text-xs">{formatContactDate(contact.lastTouchedAt)}</Typography>
</Link>
);
}
function formatContactDate(value: string) {
return new Date(value).toLocaleDateString([], { month: "short", day: "numeric" });
}
function parseLifecycleFilter(value: string): CommsContact["lifecycle"] | "all" {
if (value === "all" || value === "Lead" || value === "Customer" || value === "Champion" || value === "At risk") {
return value;