diff --git a/next.config.ts b/next.config.ts index aa1c5aa..976f677 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - // The SDK ships ESM/TS; let Next transpile it. - transpilePackages: ["@abe-kap/appshell-sdk"], + // The SDKs ship ESM; let Next transpile them. + transpilePackages: ["@abe-kap/appshell-sdk", "@insignia/iios-messaging-ui"], // The browser calls the Shell BFF same-origin under /shell (so the HttpOnly // session cookie flows). We deliberately use /shell (NOT /api) to avoid // clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF. diff --git a/package-lock.json b/package-lock.json index 3a0245f..714de0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@abe-kap/appshell-sdk": "^0.2.6", "@insignia/iios-kernel-client": "^0.1.4", + "@insignia/iios-messaging-ui": "file:../iios/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz", "clsx": "^2.1.1", "lucide-react": "^1.21.0", "next": "16.2.9", @@ -1026,6 +1027,20 @@ "socket.io-client": "^4.8.1" } }, + "node_modules/@insignia/iios-messaging-ui": { + "version": "0.1.0", + "resolved": "file:../iios/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz", + "integrity": "sha512-6pSVWACjex1sXWzVqx+42rM6vgWEJ+1ud5y3DLKogJiQmfgFZgCB8vTw0PN/5FECugXC7iGuc9rV9RsHsP+EDw==", + "peerDependencies": { + "@insignia/iios-kernel-client": "*", + "react": ">=18" + }, + "peerDependenciesMeta": { + "@insignia/iios-kernel-client": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/package.json b/package.json index 726f655..3960434 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@abe-kap/appshell-sdk": "^0.2.6", "@insignia/iios-kernel-client": "^0.1.4", + "@insignia/iios-messaging-ui": "file:../iios/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz", "clsx": "^2.1.1", "lucide-react": "^1.21.0", "next": "16.2.9", diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index de8381a..51ba53f 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -133,6 +133,20 @@ .dash-content { padding: 22px 28px 40px; width: 100%; } .sec-title { font-size: 15px; font-weight: 700; margin: 6px 0 14px; } + + /* Host for @insignia/iios-messaging-ui: a fixed-height card that maps the SDK's --miu-* tokens + onto the CRM design system, so the drop-in SDK matches the rest of the app. */ + .dash-root .miu-host { height: 620px; border: 1px solid var(--border); border-radius: 16px; overflow: hidden; } + .dash-root .miu-host .miu-messenger { + --miu-bg: var(--bg); + --miu-panel: var(--panel); + --miu-panel-2: var(--panel-2); + --miu-border: var(--border); + --miu-text: var(--text); + --miu-muted: var(--muted); + --miu-accent: var(--orange); + --miu-accent-text: #1a1206; + } /* ---- grid helpers ---- */ .grid { display: grid; gap: 16px; } diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index d052a4e..0fb2892 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -15,7 +15,7 @@ import { Support } from "./support"; import { Rules } from "./rules"; import { AiAssistant } from "./ai-assistant"; import { TeamManagement } from "./team-management"; -import { Messenger } from "./messenger"; +import { MessengerSdk } from "./messenger-sdk"; import { Inbox } from "./inbox"; import "../../app/dashboard/dashboard.css"; @@ -47,7 +47,7 @@ export function Dashboard() { : active === "support" ? : active === "rules" ? : active === "ai" ? - : active === "messenger" ? + : active === "messenger" ? : active === "inbox" ? : active === "team" ? : } diff --git a/src/components/dashboard/messenger-sdk.tsx b/src/components/dashboard/messenger-sdk.tsx new file mode 100644 index 0000000..7e440a3 --- /dev/null +++ b/src/components/dashboard/messenger-sdk.tsx @@ -0,0 +1,72 @@ +"use client"; + +// The CRM messenger, now rendered by the shared @insignia/iios-messaging-ui SDK instead of a +// bespoke in-CRM implementation. The CRM only supplies an adapter (transport) + theming; all the +// UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter); +// demo path = the SDK's own MockAdapter. + +import { useMemo } from "react"; +import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react"; +import { MessagingProvider, Messenger as SdkMessenger, type MessagingAdapter } from "@insignia/iios-messaging-ui"; +import { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock"; +import "@insignia/iios-messaging-ui/styles.css"; +import { isShellConfigured } from "@/lib/appshell"; +import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter"; +import { PageHead } from "./ui"; + +const SHELL = isShellConfigured(); + +export function MessengerSdk() { + return ( +
+ + {!SHELL && ( +
+ Demo mode — running on the SDK's mock adapter. +
+ )} +
{SHELL ? : }
+
+ ); +} + +// SHELL is a build-time constant, so exactly one of these mounts for the life of the app +// (Rules-of-Hooks safe — the other branch never renders). +function DemoHost() { + const adapter = useMemo(() => new MockAdapter(), []); + return ( + + + + ); +} + +function LiveHost() { + const { sdk } = useAppShell(); + const { user } = useAuth(); + const adapter = useMemo( + () => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id) : null), + [sdk, user?.id], + ); + if (!adapter) return
Loading…
; + return ( + + + + ); +} diff --git a/src/lib/crm-messaging-adapter.ts b/src/lib/crm-messaging-adapter.ts new file mode 100644 index 0000000..899bfb7 --- /dev/null +++ b/src/lib/crm-messaging-adapter.ts @@ -0,0 +1,165 @@ +// The CRM's implementation of the SDK's MessagingAdapter, over the be-crm data door +// (appshell `crm.messenger.*`). This is how the CRM consumes @insignia/iios-messaging-ui +// instead of embedding its own messenger: the SDK renders, this adapter transports. +// +// Data (list/open/history/send) goes through the BFF, which attaches the session + tenancy +// server-side. Live updates are polled here for a first cut; realtime (the IIOS socket the CRM +// already opens in messenger-socket.tsx) can be layered in by emitting into the same registry. + +import type { + Conversation, + Membership, + Message, + MessageEvent, + MessagingAdapter, + SendOpts, + Unsubscribe, +} from "@insignia/iios-messaging-ui"; + +/** The imperative appshell data door (useAppShell().sdk). Typed structurally so we don't couple to its class. */ +export interface DataDoor { + query(action: string, variables?: Record): Promise; + command(action: string, variables?: Record): Promise; +} + +interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" } +interface ConversationDTO { + threadId: string; subject: string | null; membership: Membership | null; + participants: string[]; unread: number; lastMessage?: string; lastAt?: string; +} +interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null } + +const POLL_MS = 4000; + +interface Poll { + cbs: Set<(e: MessageEvent) => void>; + seen: Set; + primed: boolean; + timer: ReturnType | null; +} + +export class CrmMessagingAdapter implements MessagingAdapter { + private names: Map | null = null; + private readonly polls = new Map(); + + constructor(private readonly sdk: DataDoor, private readonly me: string) {} + + currentActorId(): string { + return this.me; + } + + async listConversations(): Promise { + const [convs, names] = await Promise.all([ + this.sdk.query("crm.messenger.conversation.list", {}), + this.directory(), + ]); + return convs.map((c) => this.toConversation(c, names)); + } + + async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> { + const res = await this.sdk.command<{ threadId: string }>("crm.messenger.conversation.open", { + participantIds: p.participantIds, + ...(p.membership ? { membership: p.membership } : {}), + ...(p.subject ? { subject: p.subject } : {}), + }); + return { threadId: res.threadId }; + } + + async history(threadId: string): Promise { + const msgs = await this.sdk.query("crm.messenger.history", { threadId }); + return msgs.map((m) => this.toMessage(m)); + } + + async send(threadId: string, content: string, opts?: SendOpts): Promise { + const m = await this.sdk.command("crm.messenger.send", { + threadId, + content, + ...(opts?.attachment ? { attachment: opts.attachment } : {}), + }); + const msg = this.toMessage(m); + // Mark it seen so the poll doesn't re-emit our own message on top of useMessages' optimistic row. + this.polls.get(threadId)?.seen.add(msg.id); + return msg; + } + + subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe { + let poll = this.polls.get(threadId); + if (!poll) { + poll = { cbs: new Set(), seen: new Set(), primed: false, timer: null }; + this.polls.set(threadId, poll); + const tick = async (): Promise => { + const p = this.polls.get(threadId); + if (!p) return; + try { + const msgs = await this.sdk.query("crm.messenger.history", { threadId }); + for (const m of msgs) { + if (p.seen.has(m.interactionId)) continue; + p.seen.add(m.interactionId); + // The first pass just records what's already loaded (useMessages fetched history); + // only genuinely new messages after that are pushed to the UI. + if (p.primed) p.cbs.forEach((f) => f({ kind: "message", message: this.toMessage(m) })); + } + p.primed = true; + } catch { + /* transient BFF error — try again next tick */ + } + }; + void tick(); + poll.timer = setInterval(tick, POLL_MS); + } + poll.cbs.add(cb); + return () => { + const p = this.polls.get(threadId); + if (!p) return; + p.cbs.delete(cb); + if (p.cbs.size === 0) { + if (p.timer) clearInterval(p.timer); + this.polls.delete(threadId); + } + }; + } + + sendTyping(): void { + // No BFF verb for typing; realtime is a follow-up (the IIOS socket). No-op keeps the contract. + } + + async markRead(): Promise { + // No BFF verb for read receipts here; follow-up via the socket. No-op resolves the contract. + } + + // ── mapping ──────────────────────────────────────────────────── + private async directory(): Promise> { + if (!this.names) { + const dir = await this.sdk.query("crm.messenger.directory", { kind: "all", limit: 200 }); + this.names = new Map(dir.map((d) => [d.id, d.displayName])); + } + return this.names; + } + + private toConversation(c: ConversationDTO, names: Map): Conversation { + const others = c.participants.filter((p) => p !== this.me); + const title = + c.subject?.trim() || + others.map((id) => names.get(id) ?? id).join(", ") || + "Conversation"; + return { + threadId: c.threadId, + title, + subject: c.subject, + membership: c.membership, + participants: c.participants, + unread: c.unread, + ...(c.lastMessage ? { lastMessage: c.lastMessage } : {}), + ...(c.lastAt ? { lastAt: c.lastAt } : {}), + }; + } + + private toMessage(m: MessageDTO): Message { + return { + id: m.interactionId, + actorId: m.actorId, + text: m.text ?? "", + at: m.occurredAt, + }; + } +}