@rehers/rehers-roleplay-sdk 1.0.0 → 2.1.0

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,84 +1,144 @@
1
- # @rehers/seamless-sdk
1
+ # @rehers/rehers-roleplay-sdk
2
2
 
3
- Lightweight vanilla JS SDK for embedding Seamless roleplay call sessions. Opens a modal with an iframe — no framework dependencies.
3
+ Lightweight vanilla JS SDK for embedding roleplay call sessions. Opens a modal (dialog mode) or mounts into a container (mount mode) — no framework dependencies.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install @rehers/seamless-sdk
8
+ npm install @rehers/rehers-roleplay-sdk
9
9
  ```
10
10
 
11
11
  Or load via CDN:
12
12
 
13
13
  ```html
14
- <script src="https://unpkg.com/@rehers/seamless-sdk"></script>
14
+ <script src="https://unpkg.com/@rehers/rehers-roleplay-sdk"></script>
15
15
  ```
16
16
 
17
17
  ## Usage
18
18
 
19
19
  ```html
20
- <script src="https://unpkg.com/@rehers/seamless-sdk"></script>
20
+ <script src="https://unpkg.com/@rehers/rehers-roleplay-sdk"></script>
21
21
  <script>
22
- // 1. Initialize with a login pass from your backend
22
+ // 1. Initialize with a publishable key
23
23
  SeamlessRoleplay.init({
24
- loginPass: 'lp_abc123...',
25
- onCallEnd: function (data) {
26
- console.log('Call ended:', data.callId, data.duration);
24
+ publishableKey: 'pk_live_abc123',
25
+ userId: 'user_789',
26
+ userEmail: 'john@example.com', // optional
27
+ trialUrl: 'https://seamless.ai/pricing', // optional — shown when user not found
28
+ onReady: function() {
29
+ console.log('SDK ready');
30
+ },
31
+ onError: function(err) {
32
+ console.error('SDK error:', err.code, err.message);
27
33
  },
28
34
  });
29
35
 
30
- // 2. Open the roleplay modal for a contact
36
+ // 2. Open the roleplay modal for a contact (dialog mode)
31
37
  SeamlessRoleplay.open({
32
- contactName: 'Jane Smith',
33
- contactCompany: 'Acme Corp',
34
- contactTitle: 'VP of Sales',
35
- linkedinUrl: 'https://linkedin.com/in/jane-smith', // optional
36
- scenarioId: 'sc_123', // optional — omit to show scenario picker
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); },
37
48
  });
38
49
 
39
- // 3. Close programmatically (user can also close via X button)
40
- SeamlessRoleplay.close();
50
+ // 2b. Or mount into a container (full-page embed)
51
+ SeamlessRoleplay.mount(document.getElementById('roleplay-container'), {
52
+ name: 'Jane Smith',
53
+ domain: 'acme.com',
54
+ company: 'Acme Corp',
55
+ title: 'VP of Sales',
56
+ });
41
57
 
42
- // 4. Full cleanup when done
58
+ // Close and destroy
59
+ SeamlessRoleplay.close();
43
60
  SeamlessRoleplay.destroy();
44
61
  </script>
45
62
  ```
46
63
 
64
+ ## Trial Mode
65
+
66
+ If the user doesn't have an active account, provide a `trialUrl` during init. When the backend returns `USER_NOT_FOUND`, the SDK will render a trial screen:
67
+
68
+ ```js
69
+ SeamlessRoleplay.init({
70
+ publishableKey: 'pk_live_abc123',
71
+ userId: 'unknown_user',
72
+ trialUrl: 'https://seamless.ai/pricing',
73
+ });
74
+ ```
75
+
47
76
  ## API
48
77
 
49
78
  ### `SeamlessRoleplay.init(options)`
50
79
 
51
80
  | Option | Type | Required | Description |
52
81
  |---|---|---|---|
53
- | `loginPass` | `string` | Yes | Seamless login pass obtained from your backend |
54
- | `appOrigin` | `string` | No | Override app origin (defaults to production) |
55
- | `onClose` | `() => void` | No | Called when modal closes |
56
- | `onCallStart` | `({ callId }) => void` | No | Called when a call begins |
57
- | `onCallEnd` | `({ callId, duration? }) => void` | No | Called when a call ends |
58
- | `onError` | `({ code, message }) => void` | No | Called on error |
82
+ | `publishableKey` | `string` | Yes | Publishable API key |
83
+ | `userId` | `string` | Yes | Your user's unique identifier |
84
+ | `userEmail` | `string` | No | User email for account matching |
85
+ | `userToken` | `string` | No | Signed JWT for identity verification |
86
+ | `trialUrl` | `string` | No | URL shown when user not found |
87
+ | `origin` | `string` | No | Override app origin (dev only) |
88
+ | `onReady` | `function` | No | Called when session is ready |
89
+ | `onError` | `function` | No | Called on init error `({ code, message })` |
59
90
 
60
- ### `SeamlessRoleplay.open(contactData)`
91
+ ### `SeamlessRoleplay.open(data)`
92
+
93
+ Opens the roleplay in a modal dialog overlay.
61
94
 
62
95
  | Field | Type | Required | Description |
63
96
  |---|---|---|---|
64
- | `contactName` | `string` | Yes | Full name of the contact |
65
- | `contactCompany` | `string` | Yes | Company name |
66
- | `contactTitle` | `string` | Yes | Job title |
67
- | `linkedinUrl` | `string` | No | LinkedIn profile URL |
68
- | `scenarioId` | `string` | No | Auto-select a scenario (omit to show picker) |
97
+ | `name` | `string` | Yes | Full name of the contact |
98
+ | `domain` | `string` | Yes | Company domain (e.g. "stripe.com") |
99
+ | `company` | `string` | Yes | Company name |
100
+ | `title` | `string` | Yes | Job title |
101
+ | `companyDescription` | `string` | No | Brief company description |
102
+ | `liUrl` | `string` | No | LinkedIn profile URL |
103
+ | `onCallStarted` | `function` | No | `({ callId })` |
104
+ | `onCallEnded` | `function` | No | `({ callId, duration? })` |
105
+ | `onClose` | `function` | No | Called when dialog closes |
106
+ | `onError` | `function` | No | `({ code, message })` |
107
+
108
+ ### `SeamlessRoleplay.mount(container, data)`
109
+
110
+ Mounts the roleplay into a DOM element (full-page embed). Same `data` options as `open()`.
69
111
 
70
112
  ### `SeamlessRoleplay.close()`
71
113
 
72
- Closes the modal and notifies the iframe to end any active call.
114
+ Closes the active dialog or unmounts.
73
115
 
74
116
  ### `SeamlessRoleplay.destroy()`
75
117
 
76
- Full cleanupremoves all DOM elements and event listeners.
118
+ Tears down everything clears tokens, timers, and DOM elements.
119
+
120
+ ## Auth Flow
121
+
122
+ ```
123
+ SDK Backend
124
+ ───────────────── ─────────────────
125
+ POST /api/sdk/session
126
+ X-Publishable-Key: pk_live_abc123
127
+ Body: { userId, userEmail? }
128
+ 1. Validate key + origin
129
+ 2. Find/create user
130
+ 3. Mint JWT (1hr TTL)
131
+ ←──── { sessionToken, expiresIn }
132
+ OR { error: "USER_NOT_FOUND" }
133
+
134
+ Token stored in memory (not localStorage)
135
+ Auto-refreshes at 80% of TTL
136
+ ```
77
137
 
78
138
  ## TypeScript
79
139
 
80
- Full type declarations are included. Import types with:
140
+ Full type declarations are included:
81
141
 
82
142
  ```ts
83
- import type { SeamlessRoleplaySDK, SeamlessRoleplayInitOptions, SeamlessRoleplayContactData } from '@rehers/seamless-sdk';
143
+ import type { SeamlessRoleplaySDK, SeamlessRoleplayOpenData } from '@rehers/rehers-roleplay-sdk';
84
144
  ```
package/index.d.ts CHANGED
@@ -1,39 +1,90 @@
1
1
  export interface SeamlessRoleplayInitOptions {
2
- /** Seamless login pass for authentication */
3
- loginPass: string;
4
- /** Override the app origin (default: production URL) */
5
- appOrigin?: string;
6
- /** Called when the modal is closed */
2
+ /** Publishable API key (starts with pk_live_ or pk_test_) */
3
+ publishableKey: string;
4
+ /** Your user's unique identifier */
5
+ userId: string;
6
+ /** Optional user email for account matching */
7
+ userEmail?: string;
8
+ /** Optional signed JWT for identity verification */
9
+ userToken?: string;
10
+ /** URL shown when the user is not found (trial mode) */
11
+ trialUrl?: string;
12
+ /** Override the app origin — where the iframe loads from (for dev/testing only) */
13
+ origin?: string;
14
+ /** Called when the SDK session is ready */
15
+ onReady?: () => void;
16
+ /** Called on initialization error */
17
+ onError?: (error: { code: string; message: string }) => void;
18
+ }
19
+
20
+ export interface SeamlessRoleplayOpenData {
21
+ /** Full name of the contact */
22
+ name: string;
23
+ /** Company domain (e.g. "stripe.com") */
24
+ domain: string;
25
+ /** Company name */
26
+ company: string;
27
+ /** Job title of the contact */
28
+ title: string;
29
+ /** Optional company description */
30
+ companyDescription?: string;
31
+ /** Optional LinkedIn profile URL */
32
+ liUrl?: string;
33
+ /** Called when the roleplay call starts */
34
+ onCallStarted?: (data: { callId: string }) => void;
35
+ /** Called when the roleplay call ends */
36
+ onCallEnded?: (data: { callId: string; duration?: number }) => void;
37
+ /** Called when the dialog/mount is closed */
7
38
  onClose?: () => void;
8
- /** Called when a call begins */
9
- onCallStart?: (data: { callId: string }) => void;
10
- /** Called when a call ends */
11
- onCallEnd?: (data: { callId: string; duration?: number }) => void;
12
- /** Called on error */
39
+ /** Called on error during the session */
13
40
  onError?: (data: { code: string; message: string }) => void;
14
41
  }
15
42
 
16
- export interface SeamlessRoleplayContactData {
43
+ export interface AddToScenarioContact {
17
44
  /** Full name of the contact */
18
- contactName: string;
19
- /** Company of the contact */
20
- contactCompany: string;
45
+ name: string;
46
+ /** Company name */
47
+ company: string;
21
48
  /** Job title of the contact */
22
- contactTitle: string;
49
+ title: string;
50
+ /** Company domain (e.g. "stripe.com") */
51
+ domain: string;
23
52
  /** Optional LinkedIn profile URL */
24
- linkedinUrl?: string;
25
- /** Optional scenario ID to auto-select */
26
- scenarioId?: string;
53
+ liUrl?: string;
54
+ /** Optional company description */
55
+ companyDescription?: string;
56
+ }
57
+
58
+ export interface AddToScenarioCompleteData {
59
+ scenarioId: string;
60
+ scenarioName: string;
61
+ addedCount: number;
62
+ skippedCount: number;
63
+ }
64
+
65
+ export interface AddToScenarioOptions {
66
+ /** Array of contacts to add (1–25) */
67
+ contacts: AddToScenarioContact[];
68
+ /** Called when contacts have been successfully added */
69
+ onComplete?: (data: AddToScenarioCompleteData) => void;
70
+ /** Called when the dialog is closed */
71
+ onClose?: () => void;
72
+ /** Called on error */
73
+ onError?: (error: { code: string; message: string }) => void;
27
74
  }
28
75
 
29
76
  export interface SeamlessRoleplaySDK {
30
- /** Initialize the SDK. Must be called before open(). */
77
+ /** Initialize the SDK with a publishable key. */
31
78
  init(options: SeamlessRoleplayInitOptions): void;
32
- /** Open the roleplay modal with contact data. */
33
- open(data: SeamlessRoleplayContactData): void;
34
- /** Close the modal and notify the iframe. */
79
+ /** Open the roleplay modal for a contact (dialog mode). */
80
+ open(data: SeamlessRoleplayOpenData): void;
81
+ /** Mount the roleplay into a container element (full-page embed). */
82
+ mount(container: HTMLElement, data: SeamlessRoleplayOpenData): void;
83
+ /** Open the add-to-scenario dialog for bulk contact import. */
84
+ addToScenario(options: AddToScenarioOptions): void;
85
+ /** Close the roleplay dialog or unmount. */
35
86
  close(): void;
36
- /** Full cleanupremove DOM and event listeners. */
87
+ /** Destroy the SDK clears state, timers, and DOM. */
37
88
  destroy(): void;
38
89
  }
39
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rehers/rehers-roleplay-sdk",
3
- "version": "1.0.0",
3
+ "version": "2.1.0",
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
@@ -1,288 +1,717 @@
1
1
  /**
2
- * SeamlessRoleplay SDK
3
- * Lightweight vanilla JS SDK for embedding roleplay call sessions.
2
+ * SeamlessRoleplay SDK v2
3
+ *
4
+ * Publishable-key auth model. No build step required.
4
5
  *
5
6
  * Usage:
6
- * SeamlessRoleplay.init({ loginPass: '...', onCallEnd: (data) => {} });
7
- * SeamlessRoleplay.open({ contactName: '...', contactCompany: '...', contactTitle: '...' });
8
- * SeamlessRoleplay.close();
9
- * SeamlessRoleplay.destroy();
7
+ * SeamlessRoleplay.init({ publishableKey: 'pk_live_...', userId: 'user_789' });
8
+ * SeamlessRoleplay.open({ name: '...', domain: '...', company: '...', title: '...' });
10
9
  */
11
10
  (function () {
12
11
  "use strict";
13
12
 
14
- var DEFAULT_APP_ORIGIN = "https://roleplaywithseamless.ai";
13
+ var DEFAULT_APP_ORIGIN = "https://app.roleplaywithseamless.ai";
14
+ var DEFAULT_API_ORIGIN = "https://server.roleplaywithseamless.ai";
15
+ var SESSION_TIMEOUT_MS = 15000;
16
+ var SDK_LOG_PREFIX = "[SeamlessRoleplay]";
17
+
18
+ // ── State ───────────────────────────────────────────────────────────
19
+ var publishableKey = null;
20
+ var userId = null;
21
+ var userEmail = null;
22
+ var userToken = null;
23
+ var trialUrl = null;
24
+ var appOrigin = null;
25
+
26
+ var sessionToken = null;
27
+ var sessionExpiresAt = 0; // epoch ms
28
+ var refreshTimer = null;
29
+ var fetchingSession = null; // single-flight Promise
30
+
31
+ var initCallbacks = { onReady: null, onError: null };
32
+ var callbacks = { onCallStarted: null, onCallEnded: null, onClose: null, onError: null };
33
+ var addToScenarioCallbacks = { onComplete: null, onClose: null, onError: null };
34
+ var addToScenarioPendingContacts = null;
15
35
 
16
- var config = null;
17
36
  var overlay = null;
18
37
  var iframe = null;
38
+ var mountContainer = null;
19
39
  var pendingContactData = null;
20
- var iframeAuthenticated = false;
21
- var globalListener = null;
40
+ var mode = null; // "dialog" | "mount" | "add-to-scenario"
41
+ var listener = null;
42
+ var initCalled = false;
43
+ var closeTeardownTimer = null;
44
+
45
+ // ── Safe logging ──────────────────────────────────────────────────
22
46
 
23
- function resolveOrigin() {
24
- return (config && config.appOrigin) || DEFAULT_APP_ORIGIN;
47
+ function logError(method, err) {
48
+ try {
49
+ if (typeof console !== "undefined" && console.error) {
50
+ console.error(SDK_LOG_PREFIX, method + ":", err && err.message ? err.message : err);
51
+ }
52
+ } catch (_) {
53
+ // Never throw from the logger itself
54
+ }
55
+ }
56
+
57
+ // ── Helpers ─────────────────────────────────────────────────────────
58
+
59
+ function getOrigin() {
60
+ return appOrigin || DEFAULT_APP_ORIGIN;
61
+ }
62
+
63
+ function getApiOrigin() {
64
+ return DEFAULT_API_ORIGIN;
25
65
  }
26
66
 
27
67
  function sendToIframe(msg) {
28
- if (iframe && iframe.contentWindow) {
29
- iframe.contentWindow.postMessage(msg, resolveOrigin());
68
+ try {
69
+ if (iframe && iframe.contentWindow) {
70
+ iframe.contentWindow.postMessage(msg, getOrigin());
71
+ }
72
+ } catch (e) {
73
+ logError("sendToIframe", e);
30
74
  }
31
75
  }
32
76
 
33
- function handleMessage(event) {
34
- if (!config) return;
35
- var origin = resolveOrigin();
36
- // Allow messages from the configured app origin
37
- if (event.origin !== origin) return;
38
-
39
- var data = event.data;
40
- if (!data || typeof data.type !== "string") return;
41
-
42
- switch (data.type) {
43
- case "ROLEPLAY_READY":
44
- // Iframe is loaded — send the login pass
45
- if (config.loginPass) {
46
- sendToIframe({
47
- type: "seamless-login-pass",
48
- loginPass: config.loginPass,
49
- });
50
- }
51
- break;
52
-
53
- case "ROLEPLAY_EMBED_AUTHENTICATED":
54
- iframeAuthenticated = true;
55
- // Send any pending contact data
56
- if (pendingContactData) {
57
- sendToIframe({
58
- type: "roleplay-contact-data",
59
- contactName: pendingContactData.contactName,
60
- contactCompany: pendingContactData.contactCompany,
61
- contactTitle: pendingContactData.contactTitle,
62
- linkedinUrl: pendingContactData.linkedinUrl || undefined,
63
- scenarioId: pendingContactData.scenarioId || undefined,
64
- });
65
- pendingContactData = null;
77
+ // ── Session management ──────────────────────────────────────────────
78
+
79
+ function fetchSession() {
80
+ if (fetchingSession) return fetchingSession;
81
+
82
+ fetchingSession = new Promise(function (resolve, reject) {
83
+ var url = getApiOrigin() + "/api/sdk/session";
84
+ var body = { userId: userId };
85
+ if (userEmail) body.userEmail = userEmail;
86
+ if (userToken) body.userToken = userToken;
87
+
88
+ var xhr = new XMLHttpRequest();
89
+ xhr.open("POST", url, true);
90
+ xhr.setRequestHeader("Content-Type", "application/json");
91
+ xhr.setRequestHeader("X-Publishable-Key", publishableKey);
92
+ xhr.withCredentials = false;
93
+ xhr.timeout = SESSION_TIMEOUT_MS;
94
+
95
+ xhr.onload = function () {
96
+ fetchingSession = null;
97
+ var data;
98
+ try {
99
+ data = JSON.parse(xhr.responseText);
100
+ } catch (e) {
101
+ reject({ code: "PARSE_ERROR", message: "Invalid response from session endpoint" });
102
+ return;
66
103
  }
67
- break;
68
104
 
69
- case "ROLEPLAY_CALL_STARTED":
70
- if (typeof config.onCallStart === "function") {
71
- config.onCallStart({ callId: data.callId });
105
+ if (xhr.status === 200 && data.sessionToken) {
106
+ sessionToken = data.sessionToken;
107
+ var ttl = (data.expiresIn || 3600) * 1000;
108
+ sessionExpiresAt = Date.now() + ttl;
109
+ scheduleRefresh(ttl);
110
+ resolve({ sessionToken: sessionToken });
111
+ return;
72
112
  }
73
- break;
74
113
 
75
- case "ROLEPLAY_CALL_ENDED":
76
- if (typeof config.onCallEnd === "function") {
77
- config.onCallEnd({
78
- callId: data.callId,
79
- duration: data.duration,
80
- });
114
+ if (data.error === "USER_NOT_FOUND") {
115
+ // Trial mode not a fatal error
116
+ sessionToken = null;
117
+ resolve({ trialMode: true });
118
+ return;
81
119
  }
82
- break;
83
120
 
84
- case "ROLEPLAY_CLOSED":
85
- doClose();
86
- break;
121
+ reject({
122
+ code: data.error || "SESSION_ERROR",
123
+ message: data.message || "Failed to create session (HTTP " + xhr.status + ")",
124
+ });
125
+ };
87
126
 
88
- case "ROLEPLAY_ERROR":
89
- if (typeof config.onError === "function") {
90
- config.onError({ code: data.code, message: data.message });
91
- }
92
- break;
93
- }
94
- }
127
+ xhr.onerror = function () {
128
+ fetchingSession = null;
129
+ reject({ code: "NETWORK_ERROR", message: "Network error contacting session endpoint" });
130
+ };
131
+
132
+ xhr.ontimeout = function () {
133
+ fetchingSession = null;
134
+ reject({ code: "TIMEOUT", message: "Session request timed out after " + SESSION_TIMEOUT_MS + "ms" });
135
+ };
95
136
 
96
- function createOverlay() {
97
- var el = document.createElement("div");
98
- el.id = "seamless-roleplay-overlay";
99
- var s = el.style;
100
- s.position = "fixed";
101
- s.top = "0";
102
- s.left = "0";
103
- s.width = "100%";
104
- s.height = "100%";
105
- s.zIndex = "2147483647";
106
- s.display = "flex";
107
- s.alignItems = "center";
108
- s.justifyContent = "center";
109
- s.background = "rgba(0, 0, 0, 0.6)";
110
- s.backdropFilter = "blur(4px)";
111
-
112
- // Container
113
- var container = document.createElement("div");
114
- var cs = container.style;
115
- cs.position = "relative";
116
- cs.width = "90vw";
117
- cs.maxWidth = "1100px";
118
- cs.height = "85vh";
119
- cs.maxHeight = "800px";
120
- cs.borderRadius = "16px";
121
- cs.overflow = "hidden";
122
- cs.background = "#fff";
123
- cs.boxShadow = "0 25px 60px rgba(0,0,0,0.3)";
124
-
125
- // Close button
126
- var closeBtn = document.createElement("button");
127
- closeBtn.type = "button";
128
- closeBtn.innerHTML = "&times;";
129
- var cbs = closeBtn.style;
130
- cbs.position = "absolute";
131
- cbs.top = "12px";
132
- cbs.right = "12px";
133
- cbs.zIndex = "10";
134
- cbs.width = "32px";
135
- cbs.height = "32px";
136
- cbs.border = "none";
137
- cbs.borderRadius = "50%";
138
- cbs.background = "rgba(0,0,0,0.08)";
139
- cbs.color = "#333";
140
- cbs.fontSize = "20px";
141
- cbs.cursor = "pointer";
142
- cbs.display = "flex";
143
- cbs.alignItems = "center";
144
- cbs.justifyContent = "center";
145
- cbs.lineHeight = "1";
146
- closeBtn.addEventListener("click", function () {
147
- SeamlessRoleplay.close();
137
+ xhr.onabort = function () {
138
+ fetchingSession = null;
139
+ reject({ code: "ABORTED", message: "Session request was aborted" });
140
+ };
141
+
142
+ xhr.send(JSON.stringify(body));
148
143
  });
149
144
 
150
- // Iframe
151
- var appOrigin = resolveOrigin();
152
- var iframeEl = document.createElement("iframe");
153
- iframeEl.src = appOrigin + "/embed/roleplay-call";
154
- iframeEl.allow = "camera; microphone; display-capture";
155
- var ifs = iframeEl.style;
156
- ifs.width = "100%";
157
- ifs.height = "100%";
158
- ifs.border = "none";
159
- ifs.display = "block";
160
-
161
- container.appendChild(closeBtn);
162
- container.appendChild(iframeEl);
163
- el.appendChild(container);
164
-
165
- // Close on backdrop click
166
- el.addEventListener("click", function (e) {
167
- if (e.target === el) {
168
- SeamlessRoleplay.close();
169
- }
145
+ return fetchingSession;
146
+ }
147
+
148
+ function getSessionToken() {
149
+ if (sessionToken && Date.now() < sessionExpiresAt - 30000) {
150
+ return Promise.resolve(sessionToken);
151
+ }
152
+ return fetchSession().then(function (result) {
153
+ return result.sessionToken || null;
170
154
  });
155
+ }
171
156
 
172
- return { overlay: el, iframe: iframeEl };
157
+ function scheduleRefresh(ttlMs) {
158
+ if (refreshTimer) clearTimeout(refreshTimer);
159
+ var delay = Math.max(ttlMs * 0.8, 5000);
160
+ refreshTimer = setTimeout(function () {
161
+ fetchSession().catch(function () {
162
+ // Silent — next open() will retry
163
+ });
164
+ }, delay);
173
165
  }
174
166
 
175
- function doClose() {
176
- if (overlay && overlay.parentNode) {
177
- overlay.parentNode.removeChild(overlay);
167
+ // ── Teardown ────────────────────────────────────────────────────────
168
+
169
+ function teardown() {
170
+ try {
171
+ // Cancel any pending close→teardown timer so it can't destroy a new dialog
172
+ if (closeTeardownTimer) {
173
+ clearTimeout(closeTeardownTimer);
174
+ closeTeardownTimer = null;
175
+ }
176
+
177
+ if (mode === "dialog" || mode === "add-to-scenario") {
178
+ if (overlay && overlay.parentNode) {
179
+ overlay.parentNode.removeChild(overlay);
180
+ }
181
+ overlay = null;
182
+ } else if (mode === "mount") {
183
+ if (iframe && iframe.parentNode) {
184
+ iframe.parentNode.removeChild(iframe);
185
+ }
186
+ mountContainer = null;
187
+ }
188
+
189
+ if (listener) {
190
+ window.removeEventListener("message", listener);
191
+ listener = null;
192
+ }
193
+ } catch (e) {
194
+ logError("teardown", e);
178
195
  }
179
- overlay = null;
196
+
197
+ // Always reset state even if DOM cleanup failed
180
198
  iframe = null;
181
- iframeAuthenticated = false;
182
199
  pendingContactData = null;
183
- if (typeof config.onClose === "function") {
184
- config.onClose();
200
+ addToScenarioPendingContacts = null;
201
+ mode = null;
202
+ callbacks = { onCallStarted: null, onCallEnded: null, onClose: null, onError: null };
203
+ addToScenarioCallbacks = { onComplete: null, onClose: null, onError: null };
204
+ }
205
+
206
+ // ── Message handler ─────────────────────────────────────────────────
207
+
208
+ function dispatchInitToIframe(token) {
209
+ if (addToScenarioPendingContacts) {
210
+ sendToIframe({
211
+ type: "seamless-add-to-scenario-init",
212
+ sessionToken: token,
213
+ publishableKey: publishableKey,
214
+ userId: userId,
215
+ contacts: addToScenarioPendingContacts,
216
+ });
217
+ } else if (token && pendingContactData) {
218
+ sendToIframe({
219
+ type: "seamless-session-init",
220
+ sessionToken: token,
221
+ contact: {
222
+ name: pendingContactData.name,
223
+ domain: pendingContactData.domain,
224
+ company: pendingContactData.company,
225
+ title: pendingContactData.title,
226
+ companyDescription: pendingContactData.companyDescription || undefined,
227
+ liUrl: pendingContactData.liUrl || undefined,
228
+ },
229
+ });
230
+ } else if (trialUrl) {
231
+ sendToIframe({
232
+ type: "seamless-session-init",
233
+ trialUrl: trialUrl,
234
+ contact: null,
235
+ });
236
+ } else {
237
+ sendToIframe({
238
+ type: "seamless-session-init",
239
+ trialUrl: null,
240
+ contact: null,
241
+ });
185
242
  }
186
243
  }
187
244
 
245
+ function handleMessage(event) {
246
+ try {
247
+ if (event.origin !== getOrigin()) return;
248
+
249
+ var data = event.data;
250
+ if (!data || typeof data.type !== "string") return;
251
+
252
+ switch (data.type) {
253
+ case "ROLEPLAY_READY":
254
+ // Ensure we have a fresh token before sending init to iframe
255
+ getSessionToken()
256
+ .then(function (token) {
257
+ dispatchInitToIframe(token);
258
+ })
259
+ .catch(function () {
260
+ // Token refresh failed — send whatever we have (iframe will handle auth errors)
261
+ dispatchInitToIframe(sessionToken);
262
+ });
263
+ break;
264
+
265
+ case "ROLEPLAY_CALL_STARTED":
266
+ if (callbacks.onCallStarted) {
267
+ callbacks.onCallStarted({ callId: data.callId });
268
+ }
269
+ break;
270
+
271
+ case "ROLEPLAY_CALL_ENDED":
272
+ if (callbacks.onCallEnded) {
273
+ callbacks.onCallEnded({ callId: data.callId, duration: data.duration });
274
+ }
275
+ break;
276
+
277
+ case "ROLEPLAY_ERROR":
278
+ if (callbacks.onError) {
279
+ callbacks.onError({ code: data.code, message: data.message });
280
+ }
281
+ break;
282
+
283
+ case "ROLEPLAY_CLOSED":
284
+ var onClose = callbacks.onClose;
285
+ teardown();
286
+ if (onClose) onClose();
287
+ break;
288
+
289
+ case "ADD_TO_SCENARIO_COMPLETE":
290
+ var atsOnComplete = addToScenarioCallbacks.onComplete;
291
+ teardown();
292
+ if (atsOnComplete) {
293
+ atsOnComplete({
294
+ scenarioId: data.scenarioId,
295
+ scenarioName: data.scenarioName,
296
+ addedCount: data.addedCount,
297
+ skippedCount: data.skippedCount,
298
+ });
299
+ }
300
+ break;
301
+
302
+ case "ADD_TO_SCENARIO_ERROR":
303
+ if (addToScenarioCallbacks.onError) {
304
+ addToScenarioCallbacks.onError({ code: data.code, message: data.message });
305
+ }
306
+ break;
307
+
308
+ case "ADD_TO_SCENARIO_CLOSED":
309
+ var atsOnClose = addToScenarioCallbacks.onClose;
310
+ teardown();
311
+ if (atsOnClose) atsOnClose();
312
+ break;
313
+ }
314
+ } catch (e) {
315
+ logError("handleMessage", e);
316
+ }
317
+ }
318
+
319
+ // ── Close ───────────────────────────────────────────────────────────
320
+
321
+ function close() {
322
+ try {
323
+ sendToIframe({ type: "roleplay-close" });
324
+ if (closeTeardownTimer) clearTimeout(closeTeardownTimer);
325
+ closeTeardownTimer = setTimeout(teardown, 300);
326
+ } catch (e) {
327
+ logError("close", e);
328
+ teardown();
329
+ }
330
+ }
331
+
332
+ // ── Create iframe ───────────────────────────────────────────────────
333
+
334
+ function createIframe() {
335
+ var iframeEl = document.createElement("iframe");
336
+ iframeEl.src = getOrigin() + "/embed/roleplay-call";
337
+ iframeEl.allow = "camera; microphone; display-capture; autoplay";
338
+ iframeEl.style.width = "100%";
339
+ iframeEl.style.height = "100%";
340
+ iframeEl.style.border = "none";
341
+ iframeEl.style.display = "block";
342
+ return iframeEl;
343
+ }
344
+
345
+ // ── SDK API ─────────────────────────────────────────────────────────
346
+
188
347
  var SeamlessRoleplay = {
189
348
  /**
190
- * Initialize the SDK. Call once.
191
- * @param {Object} opts
192
- * @param {string} opts.loginPass - Seamless login pass for auth
193
- * @param {string} [opts.appOrigin] - Override app origin (default: production)
194
- * @param {Function} [opts.onClose] - Called when modal closes
195
- * @param {Function} [opts.onCallStart] - Called when call begins
196
- * @param {Function} [opts.onCallEnd] - Called when call ends ({ callId, duration })
197
- * @param {Function} [opts.onError] - Called on error ({ code, message })
349
+ * Initialize the SDK with a publishable key.
198
350
  */
199
351
  init: function (opts) {
200
- if (!opts || !opts.loginPass) {
201
- throw new Error(
202
- "SeamlessRoleplay.init() requires { loginPass: string }"
203
- );
204
- }
205
- config = {
206
- loginPass: opts.loginPass,
207
- appOrigin: opts.appOrigin || DEFAULT_APP_ORIGIN,
208
- onClose: opts.onClose || null,
209
- onCallStart: opts.onCallStart || null,
210
- onCallEnd: opts.onCallEnd || null,
211
- onError: opts.onError || null,
212
- };
352
+ try {
353
+ if (!opts || !opts.publishableKey || !opts.userId) {
354
+ logError("init", "requires { publishableKey, userId }");
355
+ return;
356
+ }
357
+
358
+ // Reset prior state if init() is called again
359
+ if (refreshTimer) {
360
+ clearTimeout(refreshTimer);
361
+ refreshTimer = null;
362
+ }
363
+ fetchingSession = null;
364
+ sessionToken = null;
365
+ sessionExpiresAt = 0;
366
+
367
+ publishableKey = opts.publishableKey;
368
+ userId = opts.userId;
369
+ userEmail = opts.userEmail || null;
370
+ userToken = opts.userToken || null;
371
+ trialUrl = opts.trialUrl || null;
372
+ appOrigin = opts.origin || null;
373
+ initCallbacks.onReady = opts.onReady || null;
374
+ initCallbacks.onError = opts.onError || null;
375
+ initCalled = true;
213
376
 
214
- // Set up the global message listener
215
- if (globalListener) {
216
- window.removeEventListener("message", globalListener);
377
+ // Fetch session immediately
378
+ fetchSession()
379
+ .then(function (result) {
380
+ if (result.trialMode && !trialUrl) {
381
+ // User not found and no trial URL configured — still call onReady
382
+ // The open() will show an error state in the iframe
383
+ }
384
+ if (initCallbacks.onReady) initCallbacks.onReady();
385
+ })
386
+ .catch(function (err) {
387
+ if (initCallbacks.onError) {
388
+ initCallbacks.onError({ code: err.code || "INIT_ERROR", message: err.message || "Initialization failed" });
389
+ }
390
+ });
391
+ } catch (e) {
392
+ logError("init", e);
393
+ if (opts && typeof opts.onError === "function") {
394
+ try { opts.onError({ code: "SDK_ERROR", message: e.message || "Unexpected error during init" }); } catch (_) {}
395
+ }
217
396
  }
218
- globalListener = handleMessage;
219
- window.addEventListener("message", globalListener);
220
397
  },
221
398
 
222
399
  /**
223
- * Open the roleplay modal with contact data.
224
- * @param {Object} data
225
- * @param {string} data.contactName
226
- * @param {string} data.contactCompany
227
- * @param {string} data.contactTitle
228
- * @param {string} [data.linkedinUrl]
229
- * @param {string} [data.scenarioId]
400
+ * Open the roleplay modal for a contact (dialog mode).
230
401
  */
231
402
  open: function (data) {
232
- if (!config) {
233
- throw new Error("Call SeamlessRoleplay.init() before open()");
234
- }
235
- if (!data || !data.contactName || !data.contactCompany || !data.contactTitle) {
236
- throw new Error(
237
- "SeamlessRoleplay.open() requires { contactName, contactCompany, contactTitle }"
238
- );
403
+ try {
404
+ if (!initCalled) {
405
+ logError("open", "init() must be called first");
406
+ return;
407
+ }
408
+ if (!data || !data.name || !data.domain || !data.company || !data.title) {
409
+ logError("open", "requires { name, domain, company, title }");
410
+ return;
411
+ }
412
+
413
+ // If already open, tear down first (also cancels any pending close timer)
414
+ if (overlay || (iframe && mode)) teardown();
415
+
416
+ pendingContactData = data;
417
+ callbacks.onCallStarted = data.onCallStarted || null;
418
+ callbacks.onCallEnded = data.onCallEnded || null;
419
+ callbacks.onClose = data.onClose || null;
420
+ callbacks.onError = data.onError || null;
421
+ mode = "dialog";
422
+
423
+ // Listen for messages
424
+ listener = handleMessage;
425
+ window.addEventListener("message", listener);
426
+
427
+ // Build overlay
428
+ var el = document.createElement("div");
429
+ el.id = "seamless-roleplay-overlay";
430
+ var s = el.style;
431
+ s.position = "fixed";
432
+ s.top = "0";
433
+ s.left = "0";
434
+ s.width = "100%";
435
+ s.height = "100%";
436
+ s.zIndex = "2147483647";
437
+ s.display = "flex";
438
+ s.alignItems = "center";
439
+ s.justifyContent = "center";
440
+ s.background = "rgba(0, 0, 0, 0.6)";
441
+ s.backdropFilter = "blur(4px)";
442
+
443
+ var container = document.createElement("div");
444
+ var cs = container.style;
445
+ cs.position = "relative";
446
+ cs.width = "90vw";
447
+ cs.maxWidth = "1100px";
448
+ cs.height = "85vh";
449
+ cs.maxHeight = "800px";
450
+ cs.borderRadius = "24px";
451
+ cs.overflow = "hidden";
452
+ cs.background = "#fff";
453
+ cs.boxShadow = "0 25px 60px rgba(0,0,0,0.3)";
454
+
455
+ var closeBtn = document.createElement("button");
456
+ closeBtn.type = "button";
457
+ closeBtn.innerHTML = "&times;";
458
+ var cbs = closeBtn.style;
459
+ cbs.position = "absolute";
460
+ cbs.top = "12px";
461
+ cbs.right = "12px";
462
+ cbs.zIndex = "10";
463
+ cbs.width = "32px";
464
+ cbs.height = "32px";
465
+ cbs.border = "none";
466
+ cbs.borderRadius = "50%";
467
+ cbs.background = "rgba(0,0,0,0.08)";
468
+ cbs.color = "#333";
469
+ cbs.fontSize = "20px";
470
+ cbs.cursor = "pointer";
471
+ cbs.display = "flex";
472
+ cbs.alignItems = "center";
473
+ cbs.justifyContent = "center";
474
+ cbs.lineHeight = "1";
475
+ closeBtn.addEventListener("click", close);
476
+
477
+ var iframeEl = createIframe();
478
+
479
+ container.appendChild(closeBtn);
480
+ container.appendChild(iframeEl);
481
+ el.appendChild(container);
482
+
483
+ el.addEventListener("click", function (e) {
484
+ if (e.target === el) close();
485
+ });
486
+
487
+ overlay = el;
488
+ iframe = iframeEl;
489
+ document.body.appendChild(overlay);
490
+ } catch (e) {
491
+ logError("open", e);
492
+ teardown();
493
+ if (data && typeof data.onError === "function") {
494
+ try { data.onError({ code: "SDK_ERROR", message: e.message || "Unexpected error during open" }); } catch (_) {}
495
+ }
239
496
  }
497
+ },
240
498
 
241
- // If already open, close first
242
- if (overlay) {
243
- doClose();
499
+ /**
500
+ * Mount the roleplay into a container element (full-page embed).
501
+ */
502
+ mount: function (container, data) {
503
+ try {
504
+ if (!initCalled) {
505
+ logError("mount", "init() must be called first");
506
+ return;
507
+ }
508
+ if (!container || !container.appendChild) {
509
+ logError("mount", "requires a DOM element as first argument");
510
+ return;
511
+ }
512
+ if (!data || !data.name || !data.domain || !data.company || !data.title) {
513
+ logError("mount", "requires { name, domain, company, title }");
514
+ return;
515
+ }
516
+
517
+ // If already open, tear down first (also cancels any pending close timer)
518
+ if (overlay || (iframe && mode)) teardown();
519
+
520
+ pendingContactData = data;
521
+ callbacks.onCallStarted = data.onCallStarted || null;
522
+ callbacks.onCallEnded = data.onCallEnded || null;
523
+ callbacks.onClose = data.onClose || null;
524
+ callbacks.onError = data.onError || null;
525
+ mode = "mount";
526
+ mountContainer = container;
527
+
528
+ // Listen for messages
529
+ listener = handleMessage;
530
+ window.addEventListener("message", listener);
531
+
532
+ var iframeEl = createIframe();
533
+ iframe = iframeEl;
534
+ container.appendChild(iframeEl);
535
+ } catch (e) {
536
+ logError("mount", e);
537
+ teardown();
538
+ if (data && typeof data.onError === "function") {
539
+ try { data.onError({ code: "SDK_ERROR", message: e.message || "Unexpected error during mount" }); } catch (_) {}
540
+ }
244
541
  }
542
+ },
543
+
544
+ /**
545
+ * Open the add-to-scenario dialog for bulk contact import.
546
+ */
547
+ addToScenario: function (opts) {
548
+ try {
549
+ if (!initCalled) {
550
+ logError("addToScenario", "init() must be called first");
551
+ return;
552
+ }
553
+ if (!opts || !opts.contacts || !Array.isArray(opts.contacts)) {
554
+ logError("addToScenario", "requires { contacts: [...] }");
555
+ return;
556
+ }
557
+ if (opts.contacts.length === 0) {
558
+ logError("addToScenario", "requires at least 1 contact");
559
+ return;
560
+ }
561
+ if (opts.contacts.length > 25) {
562
+ logError("addToScenario", "supports up to 25 contacts");
563
+ return;
564
+ }
565
+
566
+ // Validate required fields on each contact
567
+ var requiredFields = ["name", "company", "title", "domain"];
568
+ for (var i = 0; i < opts.contacts.length; i++) {
569
+ for (var j = 0; j < requiredFields.length; j++) {
570
+ if (!opts.contacts[i][requiredFields[j]]) {
571
+ logError("addToScenario", "contact at index " + i + " is missing required field: " + requiredFields[j]);
572
+ return;
573
+ }
574
+ }
575
+ }
576
+
577
+ // Tear down any existing dialog (also cancels any pending close timer)
578
+ if (overlay || (iframe && mode)) teardown();
579
+
580
+ addToScenarioPendingContacts = opts.contacts;
581
+ addToScenarioCallbacks.onComplete = opts.onComplete || null;
582
+ addToScenarioCallbacks.onClose = opts.onClose || null;
583
+ addToScenarioCallbacks.onError = opts.onError || null;
584
+ mode = "add-to-scenario";
585
+
586
+ // Listen for messages
587
+ listener = handleMessage;
588
+ window.addEventListener("message", listener);
589
+
590
+ // Build overlay — wide, compact dialog
591
+ var el = document.createElement("div");
592
+ el.id = "seamless-roleplay-overlay";
593
+ var s = el.style;
594
+ s.position = "fixed";
595
+ s.top = "0";
596
+ s.left = "0";
597
+ s.width = "100%";
598
+ s.height = "100%";
599
+ s.zIndex = "2147483647";
600
+ s.display = "flex";
601
+ s.alignItems = "center";
602
+ s.justifyContent = "center";
603
+ s.background = "rgba(0, 0, 0, 0.5)";
604
+ s.backdropFilter = "blur(4px)";
605
+
606
+ var container = document.createElement("div");
607
+ var cs = container.style;
608
+ cs.position = "relative";
609
+ cs.width = "520px";
610
+ cs.maxWidth = "95vw";
611
+ cs.height = "320px";
612
+ cs.maxHeight = "80vh";
613
+ cs.borderRadius = "24px";
614
+ cs.overflow = "hidden";
615
+ cs.background = "#fff";
616
+ cs.boxShadow = "0 25px 60px rgba(0,0,0,0.3)";
617
+
618
+ var iframeEl = document.createElement("iframe");
619
+ iframeEl.src = getOrigin() + "/embed/add-to-scenario";
620
+ iframeEl.style.width = "100%";
621
+ iframeEl.style.height = "100%";
622
+ iframeEl.style.border = "none";
623
+ iframeEl.style.display = "block";
624
+ iframeEl.style.position = "absolute";
625
+ iframeEl.style.top = "0";
626
+ iframeEl.style.left = "0";
627
+ iframeEl.style.zIndex = "1";
628
+
629
+ var closeBtn = document.createElement("button");
630
+ closeBtn.type = "button";
631
+ closeBtn.innerHTML = "&times;";
632
+ var cbs = closeBtn.style;
633
+ cbs.position = "absolute";
634
+ cbs.top = "10px";
635
+ cbs.right = "10px";
636
+ cbs.zIndex = "100";
637
+ cbs.width = "26px";
638
+ cbs.height = "26px";
639
+ cbs.border = "none";
640
+ cbs.borderRadius = "50%";
641
+ cbs.background = "rgba(0,0,0,0.08)";
642
+ cbs.color = "#333";
643
+ cbs.fontSize = "16px";
644
+ cbs.cursor = "pointer";
645
+ cbs.display = "flex";
646
+ cbs.alignItems = "center";
647
+ cbs.justifyContent = "center";
648
+ cbs.lineHeight = "1";
649
+ cbs.transition = "background 0.15s";
650
+ closeBtn.addEventListener("mouseenter", function () { cbs.background = "rgba(0,0,0,0.15)"; });
651
+ closeBtn.addEventListener("mouseleave", function () { cbs.background = "rgba(0,0,0,0.08)"; });
652
+ closeBtn.addEventListener("click", close);
245
653
 
246
- iframeAuthenticated = false;
247
- pendingContactData = data;
654
+ container.appendChild(iframeEl);
655
+ container.appendChild(closeBtn);
656
+ el.appendChild(container);
248
657
 
249
- var dom = createOverlay();
250
- overlay = dom.overlay;
251
- iframe = dom.iframe;
252
- document.body.appendChild(overlay);
658
+ el.addEventListener("click", function (e) {
659
+ if (e.target === el) close();
660
+ });
661
+
662
+ overlay = el;
663
+ iframe = iframeEl;
664
+ document.body.appendChild(overlay);
665
+ } catch (e) {
666
+ logError("addToScenario", e);
667
+ teardown();
668
+ if (opts && typeof opts.onError === "function") {
669
+ try { opts.onError({ code: "SDK_ERROR", message: e.message || "Unexpected error during addToScenario" }); } catch (_) {}
670
+ }
671
+ }
253
672
  },
254
673
 
255
674
  /**
256
- * Close the modal. Sends roleplay-close to iframe, tears down DOM.
675
+ * Close the roleplay.
257
676
  */
258
677
  close: function () {
259
- if (!overlay) return;
260
- sendToIframe({ type: "roleplay-close" });
261
- // Give iframe a moment to clean up, then force-close
262
- setTimeout(doClose, 300);
678
+ try {
679
+ close();
680
+ } catch (e) {
681
+ logError("close", e);
682
+ teardown();
683
+ }
263
684
  },
264
685
 
265
686
  /**
266
- * Full cleanupremove all event listeners and DOM.
687
+ * Destroy the SDK clears state, timers, and DOM.
267
688
  */
268
689
  destroy: function () {
269
- if (overlay) {
270
- doClose();
271
- }
272
- if (globalListener) {
273
- window.removeEventListener("message", globalListener);
274
- globalListener = null;
690
+ try {
691
+ if (refreshTimer) {
692
+ clearTimeout(refreshTimer);
693
+ refreshTimer = null;
694
+ }
695
+ teardown();
696
+ publishableKey = null;
697
+ userId = null;
698
+ userEmail = null;
699
+ userToken = null;
700
+ trialUrl = null;
701
+ sessionToken = null;
702
+ sessionExpiresAt = 0;
703
+ fetchingSession = null;
704
+ initCallbacks = { onReady: null, onError: null };
705
+ initCalled = false;
706
+ } catch (e) {
707
+ logError("destroy", e);
275
708
  }
276
- config = null;
277
709
  },
278
710
  };
279
711
 
280
- // Expose globally
281
712
  if (typeof window !== "undefined") {
282
713
  window.SeamlessRoleplay = SeamlessRoleplay;
283
714
  }
284
-
285
- // Support CommonJS / ESM
286
715
  if (typeof module !== "undefined" && module.exports) {
287
716
  module.exports = SeamlessRoleplay;
288
717
  }