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