mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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
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:
parent
d759bfbac4
commit
9c9261a8bf
@ -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.";
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user