@rehers/rehers-roleplay-sdk 2.3.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,177 +1,269 @@
1
- # @rehers/rehers-roleplay-sdk
1
+ # Roleplay SDK for the Seamless.AI Dashboard
2
2
 
3
- Lightweight vanilla JS SDK for embedding roleplay call sessions and bulk contact import. Opens a modal (dialog mode), mounts into a container (mount mode), or launches a scenario picker (add-to-scenario mode) — no framework dependencies.
3
+ This SDK is only for embedding Roleplay inside the Seamless.AI dashboard.
4
4
 
5
- ## Install
5
+ Use it in these two places:
6
+
7
+ - The dedicated Roleplay page or tab inside the Seamless dashboard
8
+ - The Contact Search screen, where clicking `Roleplay` opens a dialog for a single contact
9
+
10
+ The SDK must be initialized with the currently logged-in Seamless user before either embed flow is used.
11
+
12
+ ## Load the SDK
13
+
14
+ If Seamless is using the downloaded SDK file directly:
15
+
16
+ ```html
17
+ <script src="/path/to/roleplay-sdk.js"></script>
18
+ ```
19
+
20
+ If Seamless is using the npm package:
6
21
 
7
22
  ```bash
8
23
  npm install @rehers/rehers-roleplay-sdk
9
24
  ```
10
25
 
11
- Or load via CDN:
26
+ ## Get the logged-in Seamless user
12
27
 
13
- ```html
14
- <script src="https://unpkg.com/@rehers/rehers-roleplay-sdk"></script>
28
+ Use the existing Seamless dashboard session and call:
29
+
30
+ ```js
31
+ const meResponse = await fetch("https://api.seamless.ai/api/users/me", {
32
+ method: "GET",
33
+ credentials: "include",
34
+ headers: {
35
+ accept: "application/json, text/plain, */*",
36
+ },
37
+ }).then((res) => res.json());
15
38
  ```
16
39
 
17
- ## Usage
40
+ Normalize the response before reading fields:
41
+
42
+ ```js
43
+ const me = meResponse.data ?? meResponse;
44
+ ```
45
+
46
+ ## Required mapping for `init()`
47
+
48
+ These are the values Seamless must pass into `SeamlessRoleplay.init(...)`:
49
+
50
+ | SDK field | Seamless `/api/users/me` field |
51
+ |---|---|
52
+ | `userId` | `String(me.id)` |
53
+ | `userEmail` | `me.username` |
54
+ | `userRole` | Optional. Suggested mapping from `me.orgRole` / `me.isOrgAdmin` |
55
+
56
+ Example:
57
+
58
+ ```js
59
+ const seamlessUserId = String(me.id);
60
+ const seamlessUserEmail = me.username;
61
+ const seamlessUserRole =
62
+ me.orgRole === "owner" ? "owner" :
63
+ me.isOrgAdmin ? "admin" :
64
+ "member";
65
+ ```
66
+
67
+ For the response shape currently returned by Seamless, this means:
68
+
69
+ ```js
70
+ const seamlessUserId = String(me.id);
71
+ const seamlessUserEmail = me.username;
72
+ ```
73
+
74
+ ## Initialize the SDK once
75
+
76
+ Initialize the SDK once per page load using the logged-in Seamless user. Reuse that same initialized SDK for both the full-page embed and the Contact Search dialog.
77
+
78
+ ```js
79
+ let roleplayReadyPromise;
80
+
81
+ function ensureRoleplaySdkReady() {
82
+ if (roleplayReadyPromise) return roleplayReadyPromise;
83
+
84
+ roleplayReadyPromise = (async () => {
85
+ const meResponse = await fetch("https://api.seamless.ai/api/users/me", {
86
+ method: "GET",
87
+ credentials: "include",
88
+ headers: {
89
+ accept: "application/json, text/plain, */*",
90
+ },
91
+ }).then((res) => res.json());
92
+
93
+ const me = meResponse.data ?? meResponse;
94
+
95
+ const seamlessUserId = String(me.id);
96
+ const seamlessUserEmail = me.username;
97
+ const seamlessUserRole =
98
+ me.orgRole === "owner" ? "owner" :
99
+ me.isOrgAdmin ? "admin" :
100
+ "member";
101
+
102
+ await new Promise((resolve, reject) => {
103
+ SeamlessRoleplay.init({
104
+ publishableKey: ROLEPLAY_PUBLISHABLE_KEY,
105
+ userId: seamlessUserId,
106
+ userEmail: seamlessUserEmail,
107
+ userRole: seamlessUserRole,
108
+ onReady: resolve,
109
+ onError: reject,
110
+ });
111
+ });
112
+ })();
113
+
114
+ return roleplayReadyPromise;
115
+ }
116
+ ```
117
+
118
+ ## Embed in the full Roleplay page
119
+
120
+ Use `mount(container)` when Seamless has a dedicated Roleplay page or tab and the full content area should be the Roleplay app.
18
121
 
19
122
  ```html
20
- <script src="https://unpkg.com/@rehers/rehers-roleplay-sdk"></script>
21
- <script>
22
- // 1. Initialize with a publishable key
23
- SeamlessRoleplay.init({
24
- publishableKey: 'pk_live_abc123',
25
- userId: 'user_789',
26
- userEmail: 'john@example.com',
27
- userRole: 'member', // optional "owner", "admin", or "member"
28
- onReady: function() {
29
- console.log('SDK ready');
123
+ <div id="roleplay-root" style="width: 100%; height: 100%;"></div>
124
+ ```
125
+
126
+ ```js
127
+ async function mountRoleplayPage() {
128
+ await ensureRoleplaySdkReady();
129
+
130
+ const container = document.getElementById("roleplay-root");
131
+ SeamlessRoleplay.mount(container);
132
+ }
133
+ ```
134
+
135
+ If the Seamless page or tab is torn down, unmount the SDK:
136
+
137
+ ```js
138
+ SeamlessRoleplay.unmount();
139
+ ```
140
+
141
+ ## Embed in Contact Search
142
+
143
+ Use `open(contactData)` when the user clicks a `Roleplay` button from a single contact row on the Contact Search screen.
144
+
145
+ ```js
146
+ async function openRoleplayForContact(contact) {
147
+ await ensureRoleplaySdkReady();
148
+
149
+ SeamlessRoleplay.open({
150
+ name: contact.name,
151
+ domain: contact.domain,
152
+ company: contact.company,
153
+ title: contact.title,
154
+ liUrl: contact.linkedinUrl,
155
+ companyDescription: contact.companyDescription,
156
+ onCallStarted(data) {
157
+ console.log("Roleplay call started", data.callId);
158
+ },
159
+ onCallEnded(data) {
160
+ console.log("Roleplay call ended", data.callId, data.duration);
30
161
  },
31
- onError: function(err) {
32
- console.error('SDK error:', err.code, err.message);
162
+ onClose() {
163
+ console.log("Roleplay dialog closed");
164
+ },
165
+ onError(err) {
166
+ console.error("Roleplay dialog error", err);
33
167
  },
34
168
  });
169
+ }
170
+ ```
35
171
 
36
- // 2. Open the roleplay modal for a contact (dialog mode)
37
- SeamlessRoleplay.open({
38
- name: 'Jane Smith',
39
- domain: 'acme.com',
40
- company: 'Acme Corp',
41
- title: 'VP of Sales',
42
- companyDescription: 'Enterprise software company', // optional
43
- liUrl: 'https://linkedin.com/in/jane-smith', // optional
44
- onCallStarted: function(data) { console.log('Call started:', data.callId); },
45
- onCallEnded: function(data) { console.log('Call ended:', data.callId, data.duration); },
46
- onClose: function() { console.log('Dialog closed'); },
47
- onError: function(data) { console.error('Error:', data.code, data.message); },
48
- });
172
+ The contact object passed to `open(...)` should map to:
49
173
 
50
- // 2b. Or mount the full app into a container (full-page embed)
51
- SeamlessRoleplay.mount(document.getElementById('roleplay-container'));
174
+ | SDK field | Contact Search value |
175
+ |---|---|
176
+ | `name` | Contact full name |
177
+ | `domain` | Company domain |
178
+ | `company` | Company name |
179
+ | `title` | Contact title |
180
+ | `liUrl` | LinkedIn profile URL, if available |
181
+ | `companyDescription` | Company description, if available |
52
182
 
53
- // 2c. Or mount a call for a specific contact into a container
54
- SeamlessRoleplay.mount(document.getElementById('roleplay-container'), {
55
- name: 'Jane Smith',
56
- domain: 'acme.com',
57
- company: 'Acme Corp',
58
- title: 'VP of Sales',
59
- });
183
+ ## Optional: add contacts to a scenario
184
+
185
+ If Seamless wants to send multiple contacts into a scenario picker dialog, use `addToScenario(...)`.
186
+
187
+ ```js
188
+ async function addContactsToScenario(contacts) {
189
+ await ensureRoleplaySdkReady();
60
190
 
61
- // 3. Add contacts to a scenario in bulk
62
191
  SeamlessRoleplay.addToScenario({
63
- contacts: [
64
- { name: 'Sarah Chen', company: 'Stripe', title: 'VP of Sales', domain: 'stripe.com', liUrl: 'https://linkedin.com/in/sarachen' },
65
- { name: 'Mike Ross', company: 'Acme', title: 'CRO', domain: 'acme.com', liUrl: 'https://linkedin.com/in/mikeross' },
66
- ],
67
- onComplete: function(data) {
68
- console.log('Added', data.addedCount, 'contacts to', data.scenarioName);
192
+ contacts: contacts.map((contact) => ({
193
+ name: contact.name,
194
+ company: contact.company,
195
+ title: contact.title,
196
+ domain: contact.domain,
197
+ liUrl: contact.linkedinUrl,
198
+ companyDescription: contact.companyDescription,
199
+ })),
200
+ onComplete(data) {
201
+ console.log("Scenario import complete", data);
202
+ },
203
+ onClose() {
204
+ console.log("Add to scenario dialog closed");
205
+ },
206
+ onError(err) {
207
+ console.error("Add to scenario error", err);
69
208
  },
70
- onClose: function() { console.log('Dialog closed'); },
71
- onError: function(err) { console.error('Error:', err.code, err.message); },
72
209
  });
73
-
74
- // Close and destroy
75
- SeamlessRoleplay.close();
76
- SeamlessRoleplay.destroy();
77
- </script>
210
+ }
78
211
  ```
79
212
 
80
- ## Trial Mode
213
+ ## Minimal API surface
214
+
215
+ ### `SeamlessRoleplay.init(options)`
216
+
217
+ Initializes the SDK with the logged-in Seamless user.
218
+
219
+ ```js
220
+ SeamlessRoleplay.init({
221
+ publishableKey,
222
+ userId,
223
+ userEmail,
224
+ userRole,
225
+ onReady,
226
+ onError,
227
+ });
228
+ ```
81
229
 
82
- When the backend returns `USER_NOT_FOUND`, it includes a `paymentLink` in the response. The SDK automatically captures it and renders a trial screen directing the user to sign up — no client-side configuration needed.
230
+ ### `SeamlessRoleplay.mount(container)`
83
231
 
84
- ## API
232
+ Mounts the full Roleplay app into a dashboard container.
85
233
 
86
- ### `SeamlessRoleplay.init(options)`
234
+ ### `SeamlessRoleplay.open(contactData)`
87
235
 
88
- | Option | Type | Required | Description |
89
- |---|---|---|---|
90
- | `publishableKey` | `string` | Yes | Publishable API key |
91
- | `userId` | `string` | Yes | Your user's unique identifier |
92
- | `userEmail` | `string` | Yes | User email for secure account matching |
93
- | `userRole` | `string` | No | User role — `"owner"`, `"admin"`, or `"member"` |
94
- | `userToken` | `string` | No | Signed JWT for identity verification |
95
- | `origin` | `string` | No | Override app origin (dev only) |
96
- | `onReady` | `function` | No | Called when session is ready |
97
- | `onError` | `function` | No | Called on init error `({ code, message })` |
98
-
99
- ### `SeamlessRoleplay.open(data)`
100
-
101
- Opens the roleplay in a modal dialog overlay.
102
-
103
- | Field | Type | Required | Description |
104
- |---|---|---|---|
105
- | `name` | `string` | Yes | Full name of the contact |
106
- | `domain` | `string` | Yes | Company domain (e.g. "stripe.com") |
107
- | `company` | `string` | Yes | Company name |
108
- | `title` | `string` | Yes | Job title |
109
- | `companyDescription` | `string` | No | Brief company description |
110
- | `liUrl` | `string` | No | LinkedIn profile URL |
111
- | `onCallStarted` | `function` | No | `({ callId })` |
112
- | `onCallEnded` | `function` | No | `({ callId, duration? })` |
113
- | `onClose` | `function` | No | Called when dialog closes |
114
- | `onError` | `function` | No | `({ code, message })` |
115
-
116
- ### `SeamlessRoleplay.mount(container, data?)`
117
-
118
- Mounts into a DOM element. Two modes:
119
-
120
- - **`mount(container)`** — embeds the full Roleplay app (dashboard, scenarios, call logs)
121
- - **`mount(container, data)`** — embeds a roleplay call for a specific contact (same `data` options as `open()`)
236
+ Opens the Roleplay dialog for a single contact.
122
237
 
123
238
  ### `SeamlessRoleplay.addToScenario(options)`
124
239
 
125
- Opens a compact dialog for selecting a scenario and importing contacts in bulk.
126
-
127
- | Field | Type | Required | Description |
128
- |---|---|---|---|
129
- | `contacts` | `array` | Yes | 1–25 contacts to import |
130
- | `contacts[].name` | `string` | Yes | Full name |
131
- | `contacts[].company` | `string` | Yes | Company name |
132
- | `contacts[].title` | `string` | Yes | Job title |
133
- | `contacts[].domain` | `string` | Yes | Company domain |
134
- | `contacts[].liUrl` | `string` | No | LinkedIn profile URL |
135
- | `contacts[].companyDescription` | `string` | No | Brief company description |
136
- | `onComplete` | `function` | No | `({ scenarioId, scenarioName, addedCount, skippedCount })` |
137
- | `onClose` | `function` | No | Called when dialog closes |
138
- | `onError` | `function` | No | `({ code, message })` |
240
+ Opens the bulk add-to-scenario dialog for 1 to 25 contacts.
139
241
 
140
242
  ### `SeamlessRoleplay.close()`
141
243
 
142
- Closes the active dialog or unmounts.
244
+ Closes the active dialog.
143
245
 
144
- ### `SeamlessRoleplay.destroy()`
246
+ ### `SeamlessRoleplay.unmount()`
145
247
 
146
- Tears down everything clears tokens, timers, and DOM elements.
248
+ Unmounts the full-page Roleplay embed.
147
249
 
148
- ## Auth Flow
250
+ ### `SeamlessRoleplay.destroy()`
149
251
 
150
- ```
151
- SDK Backend
152
- ───────────────── ─────────────────
153
- POST /api/sdk/session
154
- X-Publishable-Key: pk_live_abc123
155
- Body: { userId, userEmail, userRole? }
156
- 1. Validate key + origin
157
- 2. Validate userId + userEmail combo
158
- 3. Find/create user
159
- 4. Mint JWT (1hr TTL)
160
- ←──── { sessionToken, expiresIn }
161
- OR { error: "USER_NOT_FOUND" }
162
-
163
- Token stored in memory (not localStorage)
164
- Auto-refreshes at 80% of TTL
165
- ```
252
+ Destroys the SDK state, timers, mount, and dialogs.
166
253
 
167
- ## Error Handling
254
+ ## Trial and upgrade handling
168
255
 
169
- All public methods are wrapped in try/catch — the SDK will never throw an uncaught exception that could crash the host page. Errors are logged to `console.error` with a `[SeamlessRoleplay]` prefix and routed to the relevant `onError` callback when provided.
256
+ If the backend responds with `USER_NOT_FOUND` during SDK session creation, the SDK handles the upgrade or trial UI automatically. Seamless does not need separate client-side logic for that case.
170
257
 
171
258
  ## TypeScript
172
259
 
173
- Full type declarations are included:
260
+ Type declarations are included with the SDK package.
174
261
 
175
262
  ```ts
176
- import type { SeamlessRoleplaySDK, SeamlessRoleplayOpenData, AddToScenarioOptions } from '@rehers/rehers-roleplay-sdk';
263
+ import type {
264
+ SeamlessRoleplaySDK,
265
+ SeamlessRoleplayInitOptions,
266
+ SeamlessRoleplayOpenData,
267
+ AddToScenarioOptions,
268
+ } from "@rehers/rehers-roleplay-sdk";
177
269
  ```
package/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  export interface SeamlessRoleplayInitOptions {
2
2
  /** Publishable API key (starts with pk_live_ or pk_test_) */
3
3
  publishableKey: string;
4
- /** Your user's unique identifier */
4
+ /** Logged-in Seamless user ID. Pass String(me.id) from GET /api/users/me */
5
5
  userId: string;
6
- /** User email required for secure account matching */
6
+ /** Logged-in Seamless user email. Pass me.username from GET /api/users/me */
7
7
  userEmail: string;
8
8
  /** Optional user role for syncing permissions ("owner" | "admin" | "member") */
9
9
  userRole?: "owner" | "admin" | "member";
@@ -78,13 +78,15 @@ export interface SeamlessRoleplaySDK {
78
78
  init(options: SeamlessRoleplayInitOptions): void;
79
79
  /** Open the roleplay modal for a contact (dialog mode). */
80
80
  open(data: SeamlessRoleplayOpenData): void;
81
- /** Mount the full Roleplay app into a container (no data), or a call embed for a specific contact (with data). */
82
- mount(container: HTMLElement, data?: SeamlessRoleplayOpenData): void;
81
+ /** Mount the full Roleplay app into a container. */
82
+ mount(container: HTMLElement): void;
83
83
  /** Open the add-to-scenario dialog for bulk contact import. */
84
84
  addToScenario(options: AddToScenarioOptions): void;
85
- /** Close the roleplay dialog or unmount. */
85
+ /** Close the active dialog. Does not affect mount. */
86
86
  close(): void;
87
- /** Destroy the SDK clears state, timers, and DOM. */
87
+ /** Unmount the mounted embed. Does not affect dialogs. */
88
+ unmount(): void;
89
+ /** Destroy the SDK — clears state, timers, and DOM (both mount and dialogs). */
88
90
  destroy(): void;
89
91
  }
90
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rehers/rehers-roleplay-sdk",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "Seamless Roleplay SDK — embed roleplay call sessions via a modal + iframe",
5
5
  "main": "roleplay-sdk.js",
6
6
  "types": "index.d.ts",
package/roleplay-sdk.js CHANGED
@@ -15,7 +15,7 @@
15
15
  var SESSION_TIMEOUT_MS = 15000;
16
16
  var SDK_LOG_PREFIX = "[SeamlessRoleplay]";
17
17
 
18
- // ── State ───────────────────────────────────────────────────────────
18
+ // ── Auth state ────────────────────────────────────────────────────
19
19
  var publishableKey = null;
20
20
  var userId = null;
21
21
  var userEmail = null;
@@ -30,18 +30,24 @@
30
30
  var fetchingSession = null; // single-flight Promise
31
31
 
32
32
  var initCallbacks = { onReady: null, onError: null };
33
- var callbacks = { onCallStarted: null, onCallEnded: null, onClose: null, onError: null };
34
- var addToScenarioCallbacks = { onComplete: null, onClose: null, onError: null };
35
- var addToScenarioPendingContacts = null;
33
+ var initCalled = false;
36
34
 
37
- var overlay = null;
38
- var iframe = null;
35
+ // ── Mount state (persistent embed — survives dialog open/close) ───
36
+ var mountIframe = null;
39
37
  var mountContainer = null;
40
- var pendingContactData = null;
41
- var mode = null; // "dialog" | "mount" | "add-to-scenario"
42
- var listener = null;
43
- var initCalled = false;
44
- var closeTeardownTimer = null;
38
+ var mountCallbacks = { onCallStarted: null, onCallEnded: null, onClose: null, onError: null };
39
+ var mountListener = null;
40
+
41
+ // ── Dialog state (overlay — dialog or add-to-scenario) ────────────
42
+ var dialogOverlay = null;
43
+ var dialogIframe = null;
44
+ var dialogMode = null; // "dialog" | "add-to-scenario"
45
+ var dialogContactData = null;
46
+ var dialogCallbacks = { onCallStarted: null, onCallEnded: null, onClose: null, onError: null };
47
+ var dialogAddToScenarioCallbacks = { onComplete: null, onClose: null, onError: null };
48
+ var dialogAddToScenarioPendingContacts = null;
49
+ var dialogListener = null;
50
+ var dialogCloseTeardownTimer = null;
45
51
 
46
52
  // ── Safe logging ──────────────────────────────────────────────────
47
53
 
@@ -65,17 +71,17 @@
65
71
  return DEFAULT_API_ORIGIN;
66
72
  }
67
73
 
68
- function sendToIframe(msg) {
74
+ function sendMsg(iframeEl, msg) {
69
75
  try {
70
- if (iframe && iframe.contentWindow) {
71
- iframe.contentWindow.postMessage(msg, getOrigin());
76
+ if (iframeEl && iframeEl.contentWindow) {
77
+ iframeEl.contentWindow.postMessage(msg, getOrigin());
72
78
  }
73
79
  } catch (e) {
74
- logError("sendToIframe", e);
80
+ logError("sendMsg", e);
75
81
  }
76
82
  }
77
83
 
78
- // ── Session management ──────────────────────────────────────────────
84
+ // ── Session management ────────────────────────────────────────────
79
85
 
80
86
  function fetchSession() {
81
87
  if (fetchingSession) return fetchingSession;
@@ -167,80 +173,89 @@
167
173
  }, delay);
168
174
  }
169
175
 
170
- // ── Teardown ────────────────────────────────────────────────────────
176
+ // ── Teardown (dialog only — mount is independent) ─────────────────
171
177
 
172
- function teardown() {
178
+ function teardownDialog() {
173
179
  try {
174
- // Cancel any pending close→teardown timer so it can't destroy a new dialog
175
- if (closeTeardownTimer) {
176
- clearTimeout(closeTeardownTimer);
177
- closeTeardownTimer = null;
180
+ if (dialogCloseTeardownTimer) {
181
+ clearTimeout(dialogCloseTeardownTimer);
182
+ dialogCloseTeardownTimer = null;
178
183
  }
179
184
 
180
- if (mode === "dialog" || mode === "add-to-scenario") {
181
- if (overlay && overlay.parentNode) {
182
- overlay.parentNode.removeChild(overlay);
183
- }
184
- overlay = null;
185
- } else if (mode === "mount") {
186
- if (iframe && iframe.parentNode) {
187
- iframe.parentNode.removeChild(iframe);
188
- }
189
- mountContainer = null;
185
+ if (dialogOverlay && dialogOverlay.parentNode) {
186
+ dialogOverlay.parentNode.removeChild(dialogOverlay);
190
187
  }
188
+ dialogOverlay = null;
189
+
190
+ if (dialogListener) {
191
+ window.removeEventListener("message", dialogListener);
192
+ dialogListener = null;
193
+ }
194
+ } catch (e) {
195
+ logError("teardownDialog", e);
196
+ }
191
197
 
192
- if (listener) {
193
- window.removeEventListener("message", listener);
194
- listener = null;
198
+ dialogIframe = null;
199
+ dialogContactData = null;
200
+ dialogAddToScenarioPendingContacts = null;
201
+ dialogMode = null;
202
+ dialogCallbacks = { onCallStarted: null, onCallEnded: null, onClose: null, onError: null };
203
+ dialogAddToScenarioCallbacks = { onComplete: null, onClose: null, onError: null };
204
+ }
205
+
206
+ function teardownMount() {
207
+ try {
208
+ if (mountIframe && mountIframe.parentNode) {
209
+ mountIframe.parentNode.removeChild(mountIframe);
210
+ }
211
+ if (mountListener) {
212
+ window.removeEventListener("message", mountListener);
213
+ mountListener = null;
195
214
  }
196
215
  } catch (e) {
197
- logError("teardown", e);
216
+ logError("teardownMount", e);
198
217
  }
199
218
 
200
- // Always reset state even if DOM cleanup failed
201
- iframe = null;
202
- pendingContactData = null;
203
- addToScenarioPendingContacts = null;
204
- mode = null;
205
- callbacks = { onCallStarted: null, onCallEnded: null, onClose: null, onError: null };
206
- addToScenarioCallbacks = { onComplete: null, onClose: null, onError: null };
219
+ mountIframe = null;
220
+ mountContainer = null;
221
+ mountCallbacks = { onCallStarted: null, onCallEnded: null, onClose: null, onError: null };
207
222
  }
208
223
 
209
- // ── Message handler ─────────────────────────────────────────────────
224
+ // ── Message dispatch ──────────────────────────────────────────────
210
225
 
211
- function dispatchInitToIframe(token) {
212
- if (addToScenarioPendingContacts) {
226
+ function dispatchInitToTarget(targetIframe, contactData, atsContacts) {
227
+ if (atsContacts) {
213
228
  var msg = {
214
229
  type: "seamless-add-to-scenario-init",
215
- sessionToken: token,
230
+ sessionToken: sessionToken,
216
231
  publishableKey: publishableKey,
217
232
  userId: userId,
218
- contacts: addToScenarioPendingContacts,
233
+ contacts: atsContacts,
219
234
  };
220
235
  if (paymentLink) msg.paymentLink = paymentLink;
221
- sendToIframe(msg);
222
- } else if (token) {
236
+ sendMsg(targetIframe, msg);
237
+ } else if (sessionToken) {
223
238
  var msg = {
224
239
  type: "seamless-session-init",
225
- sessionToken: token,
226
- contact: pendingContactData ? {
227
- name: pendingContactData.name,
228
- domain: pendingContactData.domain,
229
- company: pendingContactData.company,
230
- title: pendingContactData.title,
231
- companyDescription: pendingContactData.companyDescription || undefined,
232
- liUrl: pendingContactData.liUrl || undefined,
240
+ sessionToken: sessionToken,
241
+ contact: contactData ? {
242
+ name: contactData.name,
243
+ domain: contactData.domain,
244
+ company: contactData.company,
245
+ title: contactData.title,
246
+ companyDescription: contactData.companyDescription || undefined,
247
+ liUrl: contactData.liUrl || undefined,
233
248
  } : null,
234
249
  };
235
- sendToIframe(msg);
250
+ sendMsg(targetIframe, msg);
236
251
  } else if (paymentLink) {
237
- sendToIframe({
252
+ sendMsg(targetIframe, {
238
253
  type: "seamless-session-init",
239
254
  paymentLink: paymentLink,
240
255
  contact: null,
241
256
  });
242
257
  } else {
243
- sendToIframe({
258
+ sendMsg(targetIframe, {
244
259
  type: "seamless-session-init",
245
260
  paymentLink: null,
246
261
  contact: null,
@@ -248,53 +263,104 @@
248
263
  }
249
264
  }
250
265
 
251
- function handleMessage(event) {
266
+ // ── Mount message handler ─────────────────────────────────────────
267
+
268
+ function handleMountMessage(event) {
252
269
  try {
253
270
  if (event.origin !== getOrigin()) return;
271
+ if (!mountIframe || !event.source || event.source !== mountIframe.contentWindow) return;
254
272
 
255
273
  var data = event.data;
256
274
  if (!data || typeof data.type !== "string") return;
257
275
 
258
276
  switch (data.type) {
259
277
  case "ROLEPLAY_READY":
260
- // Ensure we have a fresh token before sending init to iframe
261
278
  getSessionToken()
262
279
  .then(function (token) {
263
- dispatchInitToIframe(token);
280
+ dispatchInitToTarget(mountIframe, null, null);
264
281
  })
265
282
  .catch(function () {
266
- // Token refresh failed — send whatever we have (iframe will handle auth errors)
267
- dispatchInitToIframe(sessionToken);
283
+ dispatchInitToTarget(mountIframe, null, null);
268
284
  });
269
285
  break;
270
286
 
271
287
  case "ROLEPLAY_CALL_STARTED":
272
- if (callbacks.onCallStarted) {
273
- callbacks.onCallStarted({ callId: data.callId });
288
+ if (mountCallbacks.onCallStarted) {
289
+ mountCallbacks.onCallStarted({ callId: data.callId });
274
290
  }
275
291
  break;
276
292
 
277
293
  case "ROLEPLAY_CALL_ENDED":
278
- if (callbacks.onCallEnded) {
279
- callbacks.onCallEnded({ callId: data.callId, duration: data.duration });
294
+ if (mountCallbacks.onCallEnded) {
295
+ mountCallbacks.onCallEnded({ callId: data.callId, duration: data.duration });
280
296
  }
281
297
  break;
282
298
 
283
299
  case "ROLEPLAY_ERROR":
284
- if (callbacks.onError) {
285
- callbacks.onError({ code: data.code, message: data.message });
300
+ if (mountCallbacks.onError) {
301
+ mountCallbacks.onError({ code: data.code, message: data.message });
286
302
  }
287
303
  break;
288
304
 
289
305
  case "ROLEPLAY_CLOSED":
290
- var onClose = callbacks.onClose;
291
- teardown();
306
+ var onClose = mountCallbacks.onClose;
307
+ teardownMount();
308
+ if (onClose) onClose();
309
+ break;
310
+ }
311
+ } catch (e) {
312
+ logError("handleMountMessage", e);
313
+ }
314
+ }
315
+
316
+ // ── Dialog message handler ────────────────────────────────────────
317
+
318
+ function handleDialogMessage(event) {
319
+ try {
320
+ if (event.origin !== getOrigin()) return;
321
+ if (!dialogIframe || !event.source || event.source !== dialogIframe.contentWindow) return;
322
+
323
+ var data = event.data;
324
+ if (!data || typeof data.type !== "string") return;
325
+
326
+ switch (data.type) {
327
+ case "ROLEPLAY_READY":
328
+ getSessionToken()
329
+ .then(function (token) {
330
+ dispatchInitToTarget(dialogIframe, dialogContactData, dialogAddToScenarioPendingContacts);
331
+ })
332
+ .catch(function () {
333
+ dispatchInitToTarget(dialogIframe, dialogContactData, dialogAddToScenarioPendingContacts);
334
+ });
335
+ break;
336
+
337
+ case "ROLEPLAY_CALL_STARTED":
338
+ if (dialogCallbacks.onCallStarted) {
339
+ dialogCallbacks.onCallStarted({ callId: data.callId });
340
+ }
341
+ break;
342
+
343
+ case "ROLEPLAY_CALL_ENDED":
344
+ if (dialogCallbacks.onCallEnded) {
345
+ dialogCallbacks.onCallEnded({ callId: data.callId, duration: data.duration });
346
+ }
347
+ break;
348
+
349
+ case "ROLEPLAY_ERROR":
350
+ if (dialogCallbacks.onError) {
351
+ dialogCallbacks.onError({ code: data.code, message: data.message });
352
+ }
353
+ break;
354
+
355
+ case "ROLEPLAY_CLOSED":
356
+ var onClose = dialogCallbacks.onClose;
357
+ teardownDialog();
292
358
  if (onClose) onClose();
293
359
  break;
294
360
 
295
361
  case "ADD_TO_SCENARIO_COMPLETE":
296
- var atsOnComplete = addToScenarioCallbacks.onComplete;
297
- teardown();
362
+ var atsOnComplete = dialogAddToScenarioCallbacks.onComplete;
363
+ teardownDialog();
298
364
  if (atsOnComplete) {
299
365
  atsOnComplete({
300
366
  scenarioId: data.scenarioId,
@@ -306,36 +372,36 @@
306
372
  break;
307
373
 
308
374
  case "ADD_TO_SCENARIO_ERROR":
309
- if (addToScenarioCallbacks.onError) {
310
- addToScenarioCallbacks.onError({ code: data.code, message: data.message });
375
+ if (dialogAddToScenarioCallbacks.onError) {
376
+ dialogAddToScenarioCallbacks.onError({ code: data.code, message: data.message });
311
377
  }
312
378
  break;
313
379
 
314
380
  case "ADD_TO_SCENARIO_CLOSED":
315
- var atsOnClose = addToScenarioCallbacks.onClose;
316
- teardown();
381
+ var atsOnClose = dialogAddToScenarioCallbacks.onClose;
382
+ teardownDialog();
317
383
  if (atsOnClose) atsOnClose();
318
384
  break;
319
385
  }
320
386
  } catch (e) {
321
- logError("handleMessage", e);
387
+ logError("handleDialogMessage", e);
322
388
  }
323
389
  }
324
390
 
325
- // ── Close ───────────────────────────────────────────────────────────
391
+ // ── Close (dialog only) ───────────────────────────────────────────
326
392
 
327
- function close() {
393
+ function closeDialog() {
328
394
  try {
329
- sendToIframe({ type: "roleplay-close" });
330
- if (closeTeardownTimer) clearTimeout(closeTeardownTimer);
331
- closeTeardownTimer = setTimeout(teardown, 300);
395
+ sendMsg(dialogIframe, { type: "roleplay-close" });
396
+ if (dialogCloseTeardownTimer) clearTimeout(dialogCloseTeardownTimer);
397
+ dialogCloseTeardownTimer = setTimeout(teardownDialog, 300);
332
398
  } catch (e) {
333
399
  logError("close", e);
334
- teardown();
400
+ teardownDialog();
335
401
  }
336
402
  }
337
403
 
338
- // ── Create iframe ───────────────────────────────────────────────────
404
+ // ── Create iframe ─────────────────────────────────────────────────
339
405
 
340
406
  function createIframe(path) {
341
407
  var iframeEl = document.createElement("iframe");
@@ -348,7 +414,7 @@
348
414
  return iframeEl;
349
415
  }
350
416
 
351
- // ── SDK API ─────────────────────────────────────────────────────────
417
+ // ── SDK API ───────────────────────────────────────────────────────
352
418
 
353
419
  var SeamlessRoleplay = {
354
420
  /**
@@ -416,19 +482,19 @@
416
482
  return;
417
483
  }
418
484
 
419
- // If already open, tear down first (also cancels any pending close timer)
420
- if (overlay || (iframe && mode)) teardown();
485
+ // Tear down any existing dialog (NOT the mount)
486
+ if (dialogOverlay || dialogIframe) teardownDialog();
421
487
 
422
- pendingContactData = data;
423
- callbacks.onCallStarted = data.onCallStarted || null;
424
- callbacks.onCallEnded = data.onCallEnded || null;
425
- callbacks.onClose = data.onClose || null;
426
- callbacks.onError = data.onError || null;
427
- mode = "dialog";
488
+ dialogContactData = data;
489
+ dialogCallbacks.onCallStarted = data.onCallStarted || null;
490
+ dialogCallbacks.onCallEnded = data.onCallEnded || null;
491
+ dialogCallbacks.onClose = data.onClose || null;
492
+ dialogCallbacks.onError = data.onError || null;
493
+ dialogMode = "dialog";
428
494
 
429
495
  // Listen for messages
430
- listener = handleMessage;
431
- window.addEventListener("message", listener);
496
+ dialogListener = handleDialogMessage;
497
+ window.addEventListener("message", dialogListener);
432
498
 
433
499
  // Build overlay
434
500
  var el = document.createElement("div");
@@ -478,7 +544,7 @@
478
544
  cbs.alignItems = "center";
479
545
  cbs.justifyContent = "center";
480
546
  cbs.lineHeight = "1";
481
- closeBtn.addEventListener("click", close);
547
+ closeBtn.addEventListener("click", closeDialog);
482
548
 
483
549
  var iframeEl = createIframe();
484
550
 
@@ -487,15 +553,15 @@
487
553
  el.appendChild(container);
488
554
 
489
555
  el.addEventListener("click", function (e) {
490
- if (e.target === el) close();
556
+ if (e.target === el) closeDialog();
491
557
  });
492
558
 
493
- overlay = el;
494
- iframe = iframeEl;
495
- document.body.appendChild(overlay);
559
+ dialogOverlay = el;
560
+ dialogIframe = iframeEl;
561
+ document.body.appendChild(dialogOverlay);
496
562
  } catch (e) {
497
563
  logError("open", e);
498
- teardown();
564
+ teardownDialog();
499
565
  if (data && typeof data.onError === "function") {
500
566
  try { data.onError({ code: "SDK_ERROR", message: e.message || "Unexpected error during open" }); } catch (_) {}
501
567
  }
@@ -503,13 +569,9 @@
503
569
  },
504
570
 
505
571
  /**
506
- * Mount the roleplay into a container element.
507
- *
508
- * Two modes:
509
- * mount(container) — full app embed (dashboard, scenarios, call logs, etc.)
510
- * mount(container, contactData) — roleplay call embed for a specific contact
572
+ * Mount the full Roleplay app into a container element.
511
573
  */
512
- mount: function (container, data) {
574
+ mount: function (container) {
513
575
  try {
514
576
  if (!initCalled) {
515
577
  logError("mount", "init() must be called first");
@@ -520,38 +582,22 @@
520
582
  return;
521
583
  }
522
584
 
523
- // If contact data is provided, validate required fields
524
- var hasContactData = data && data.name && data.domain && data.company && data.title;
525
- if (data && !hasContactData && (data.name || data.domain || data.company || data.title)) {
526
- logError("mount", "contact data requires { name, domain, company, title }");
527
- return;
528
- }
529
-
530
- // If already open, tear down first (also cancels any pending close timer)
531
- if (overlay || (iframe && mode)) teardown();
585
+ // Tear down any existing mount (re-mount)
586
+ if (mountIframe) teardownMount();
532
587
 
533
- pendingContactData = hasContactData ? data : null;
534
- callbacks.onCallStarted = (data && data.onCallStarted) || null;
535
- callbacks.onCallEnded = (data && data.onCallEnded) || null;
536
- callbacks.onClose = (data && data.onClose) || null;
537
- callbacks.onError = (data && data.onError) || null;
538
- mode = "mount";
588
+ mountCallbacks = { onCallStarted: null, onCallEnded: null, onClose: null, onError: null };
539
589
  mountContainer = container;
540
590
 
541
591
  // Listen for messages
542
- listener = handleMessage;
543
- window.addEventListener("message", listener);
592
+ mountListener = handleMountMessage;
593
+ window.addEventListener("message", mountListener);
544
594
 
545
- // No contact data → full app embed at root; with contact → /embed/roleplay-call
546
- var iframeEl = createIframe(hasContactData ? "/embed/roleplay-call" : "/");
547
- iframe = iframeEl;
595
+ var iframeEl = createIframe("/");
596
+ mountIframe = iframeEl;
548
597
  container.appendChild(iframeEl);
549
598
  } catch (e) {
550
599
  logError("mount", e);
551
- teardown();
552
- if (data && typeof data.onError === "function") {
553
- try { data.onError({ code: "SDK_ERROR", message: e.message || "Unexpected error during mount" }); } catch (_) {}
554
- }
600
+ teardownMount();
555
601
  }
556
602
  },
557
603
 
@@ -588,18 +634,18 @@
588
634
  }
589
635
  }
590
636
 
591
- // Tear down any existing dialog (also cancels any pending close timer)
592
- if (overlay || (iframe && mode)) teardown();
637
+ // Tear down any existing dialog (NOT the mount)
638
+ if (dialogOverlay || dialogIframe) teardownDialog();
593
639
 
594
- addToScenarioPendingContacts = opts.contacts;
595
- addToScenarioCallbacks.onComplete = opts.onComplete || null;
596
- addToScenarioCallbacks.onClose = opts.onClose || null;
597
- addToScenarioCallbacks.onError = opts.onError || null;
598
- mode = "add-to-scenario";
640
+ dialogAddToScenarioPendingContacts = opts.contacts;
641
+ dialogAddToScenarioCallbacks.onComplete = opts.onComplete || null;
642
+ dialogAddToScenarioCallbacks.onClose = opts.onClose || null;
643
+ dialogAddToScenarioCallbacks.onError = opts.onError || null;
644
+ dialogMode = "add-to-scenario";
599
645
 
600
646
  // Listen for messages
601
- listener = handleMessage;
602
- window.addEventListener("message", listener);
647
+ dialogListener = handleDialogMessage;
648
+ window.addEventListener("message", dialogListener);
603
649
 
604
650
  // Build overlay — wide, compact dialog
605
651
  var el = document.createElement("div");
@@ -663,22 +709,22 @@
663
709
  cbs.transition = "background 0.15s";
664
710
  closeBtn.addEventListener("mouseenter", function () { cbs.background = "rgba(0,0,0,0.15)"; });
665
711
  closeBtn.addEventListener("mouseleave", function () { cbs.background = "rgba(0,0,0,0.08)"; });
666
- closeBtn.addEventListener("click", close);
712
+ closeBtn.addEventListener("click", closeDialog);
667
713
 
668
714
  container.appendChild(iframeEl);
669
715
  container.appendChild(closeBtn);
670
716
  el.appendChild(container);
671
717
 
672
718
  el.addEventListener("click", function (e) {
673
- if (e.target === el) close();
719
+ if (e.target === el) closeDialog();
674
720
  });
675
721
 
676
- overlay = el;
677
- iframe = iframeEl;
678
- document.body.appendChild(overlay);
722
+ dialogOverlay = el;
723
+ dialogIframe = iframeEl;
724
+ document.body.appendChild(dialogOverlay);
679
725
  } catch (e) {
680
726
  logError("addToScenario", e);
681
- teardown();
727
+ teardownDialog();
682
728
  if (opts && typeof opts.onError === "function") {
683
729
  try { opts.onError({ code: "SDK_ERROR", message: e.message || "Unexpected error during addToScenario" }); } catch (_) {}
684
730
  }
@@ -686,19 +732,30 @@
686
732
  },
687
733
 
688
734
  /**
689
- * Close the roleplay.
735
+ * Close the active dialog. Does not affect mount.
690
736
  */
691
737
  close: function () {
692
738
  try {
693
- close();
739
+ closeDialog();
694
740
  } catch (e) {
695
741
  logError("close", e);
696
- teardown();
742
+ teardownDialog();
743
+ }
744
+ },
745
+
746
+ /**
747
+ * Unmount the mounted embed. Does not affect dialogs.
748
+ */
749
+ unmount: function () {
750
+ try {
751
+ teardownMount();
752
+ } catch (e) {
753
+ logError("unmount", e);
697
754
  }
698
755
  },
699
756
 
700
757
  /**
701
- * Destroy the SDK — clears state, timers, and DOM.
758
+ * Destroy the SDK — clears state, timers, and DOM (both mount and dialogs).
702
759
  */
703
760
  destroy: function () {
704
761
  try {
@@ -706,7 +763,8 @@
706
763
  clearTimeout(refreshTimer);
707
764
  refreshTimer = null;
708
765
  }
709
- teardown();
766
+ teardownDialog();
767
+ teardownMount();
710
768
  publishableKey = null;
711
769
  userId = null;
712
770
  userEmail = null;