diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 93b1c827b..dd4e9dc7e 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -1,16 +1,6 @@ { "version": 1, "hooks": { - "afterFileEdit": [ - { - "command": "./hooks/lint-file.sh" - } - ], - "stop": [ - { - "command": "./hooks/stop-check.sh" - } - ] } } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/app-enabled-guard.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/app-enabled-guard.tsx index d348ce553..39eea5bcf 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/app-enabled-guard.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/app-enabled-guard.tsx @@ -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"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/comms/comms-components.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/comms/comms-components.tsx new file mode 100644 index 000000000..33c62e04f --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/comms/comms-components.tsx @@ -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([ + ["email", EnvelopeSimpleIcon], + ["slack", SlackLogoIcon], + ["discord", DiscordLogoIcon], + ["support", TicketIcon], + ["push", BellRingingIcon], +]); + +const platformColorMap = new Map([ + ["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 ( + + ); +} + +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 ( + + + {contact.name.slice(0, 2)} + + ); +} + +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 ( + + {contact.company} · {contact.role} · {primary} + + ); +} + +export function ContactMiniCard({ contact, projectId }: { contact: CommsContact, projectId: string }) { + return ( + + +
+ +
+
+ {contact.name} + +
+ +
+ {contact.tags.slice(0, 3).map((tag) => ( + + ))} +
+
+
+
+ + ); +} + +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 ( + + ); +} + +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 ( + +
+
+
+
+ + +
+ + {message.subject ?? topic.title} + + + {message.direction === "inbound" ? "Received" : "Sent"} via {message.channelLabel} · {formatCommsTimestamp(message.timestamp)} + +
+ +
+
+
+
+ {message.platform === "email" && message.htmlBody != null ? ( +
+ {message.text} +
+ ) : ( +
+ {message.text} +
+ )} + {message.attachments.length > 0 ? ( +
+ Attachments +
+ {message.attachments.map((attachment) => ( + + ))} +
+
+ ) : null} + +
+ +
+ AI topic assignment + {message.aiReason} +
+
+
+
+
+
+ +
+ + {contact.name} + + +
+
+
+ + + + +
+ + Reply in topic + +
+
+
+ ); +} + +export function InfoRow({ label, value }: { label: string, value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +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 ( +
+
+ +
+ +
+
+
+
+ {isOutbound ? "Your team" : contact.name} + {COMMS_PLATFORM_LABELS[message.platform]} +
+ {message.subject != null ? {message.subject} : null} + {message.text} +
+
+ ); +} + +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(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 ( + +
+
+ Platform + { + const nextPlatform = parsePlatform(value); + setPlatform(nextPlatform); + setChannelLabel(getDefaultChannelLabel(nextPlatform, contact)); + }} + size="md" + /> +
+
+ Contact + { + 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" + /> +
+
+ + {platformHelp} + + {platform === "email" ? ( +
+ + setRecipients(event.target.value)} placeholder="customer@example.com" /> + + + setSubject(event.target.value)} placeholder="Subject" /> + +
+ ) : null} + + {platform === "discord" || platform === "slack" || platform === "support" ? ( +
+ + setChannelLabel(event.target.value)} placeholder="Choose destination" /> + + + setRecipients(event.target.value)} placeholder="Contact handle or message id" /> + +
+ ) : null} + + {platform === "push" ? ( +
+ + setChannelLabel(event.target.value)} placeholder="Production Android cohort" /> + + + setSubject(event.target.value)} placeholder="Notification title" /> + +
+ ) : null} + + +