@callembed/elements 0.0.0-stage → 0.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/dist/index.js ADDED
@@ -0,0 +1,528 @@
1
+ import { CallEmbed, CallEmbedError } from '@callembed/sdk';
2
+ import { resolveCopy, friendlyErrorMessage } from '@callembed/sdk/ui';
3
+ export { enCopy, friendlyErrorMessage, resolveCopy, zhTwCopy } from '@callembed/sdk/ui';
4
+
5
+ // src/callembed-button.ts
6
+ function statusLabel(copy, uiState, sdkStatus) {
7
+ switch (uiState) {
8
+ case "idle":
9
+ return copy.ready;
10
+ case "ringing":
11
+ return sdkStatus === "connecting" ? copy.connecting : copy.ringing;
12
+ case "connected":
13
+ return copy.answered;
14
+ case "ended":
15
+ return copy.ended;
16
+ case "error":
17
+ return copy.error;
18
+ default:
19
+ return copy.ready;
20
+ }
21
+ }
22
+ function runtimeForCopy(locale, copyOverrides = {}) {
23
+ return {
24
+ publishableKey: "",
25
+ apiBaseUrl: "",
26
+ locale,
27
+ copyOverrides,
28
+ branding: true,
29
+ debug: false
30
+ };
31
+ }
32
+
33
+ // src/styles.ts
34
+ var ELEMENT_STYLES = `
35
+ :where(.ce-el-button, .ce-el-status, .ce-el-mute) {
36
+ font-family: var(
37
+ --ce-font,
38
+ "Noto Sans TC",
39
+ "IBM Plex Sans",
40
+ "Segoe UI",
41
+ sans-serif
42
+ );
43
+ color: var(--ce-fg, #e8eef4);
44
+ }
45
+
46
+ .ce-el-root {
47
+ display: inline-flex;
48
+ flex-direction: column;
49
+ align-items: flex-start;
50
+ gap: 0.5rem;
51
+ }
52
+
53
+ .ce-el-button {
54
+ appearance: none;
55
+ display: inline-block;
56
+ border: 1px solid var(--ce-border, #2a3a46);
57
+ border-radius: var(--ce-radius, 0);
58
+ background: color-mix(in srgb, var(--ce-accent, #5eb0d8) 25%, transparent);
59
+ color: var(--ce-fg, #e8eef4);
60
+ padding: 0.7rem 1.1rem;
61
+ font-size: inherit;
62
+ cursor: pointer;
63
+ }
64
+
65
+ .ce-el-button:hover {
66
+ border-color: var(--ce-accent, #5eb0d8);
67
+ }
68
+
69
+ .ce-el-button:disabled {
70
+ opacity: 0.5;
71
+ cursor: not-allowed;
72
+ }
73
+
74
+ .ce-el-button[data-state="ringing"],
75
+ .ce-el-button[data-state="connected"] {
76
+ border-color: color-mix(
77
+ in srgb,
78
+ var(--ce-danger, #c44b4b) 55%,
79
+ var(--ce-border, #2a3a46)
80
+ );
81
+ color: var(--ce-error, #f0c4c4);
82
+ background: transparent;
83
+ }
84
+
85
+ .ce-el-status {
86
+ margin: 0;
87
+ color: var(--ce-muted, #9aadb8);
88
+ font-size: 0.9rem;
89
+ line-height: 1.4;
90
+ min-height: 1.25em;
91
+ }
92
+
93
+ .ce-el-status[data-tone="error"] {
94
+ color: var(--ce-error, #f0c4c4);
95
+ }
96
+
97
+ .ce-el-status[data-tone="ok"] {
98
+ color: var(--ce-ok, #9fd6c0);
99
+ }
100
+
101
+ .ce-el-mute {
102
+ appearance: none;
103
+ border: 1px solid var(--ce-border, #2a3a46);
104
+ border-radius: var(--ce-radius, 0);
105
+ background: transparent;
106
+ color: inherit;
107
+ padding: 0.45rem 0.75rem;
108
+ font: inherit;
109
+ cursor: pointer;
110
+ }
111
+
112
+ .ce-el-mute:hover {
113
+ border-color: var(--ce-accent, #5eb0d8);
114
+ }
115
+
116
+ .ce-el-mute:disabled {
117
+ opacity: 0.5;
118
+ cursor: not-allowed;
119
+ }
120
+ `;
121
+
122
+ // src/types.ts
123
+ function toUiState(status) {
124
+ switch (status) {
125
+ case "connecting":
126
+ case "ringing":
127
+ return "ringing";
128
+ case "answered":
129
+ return "connected";
130
+ case "ended":
131
+ return "ended";
132
+ case "error":
133
+ return "error";
134
+ case "idle":
135
+ default:
136
+ return "idle";
137
+ }
138
+ }
139
+
140
+ // src/callembed-button.ts
141
+ var TAG = "callembed-button";
142
+ function toErrorState(err) {
143
+ if (err instanceof CallEmbedError) {
144
+ return { code: err.code, message: err.message };
145
+ }
146
+ return {
147
+ code: "call_failed",
148
+ message: err instanceof Error ? err.message : String(err)
149
+ };
150
+ }
151
+ function isActiveStatus(status) {
152
+ return status === "connecting" || status === "ringing" || status === "answered";
153
+ }
154
+ function parseLocale(raw) {
155
+ if (raw === "en" || raw === "zh-TW") return raw;
156
+ return void 0;
157
+ }
158
+ var CallEmbedButtonElement = class extends HTMLElement {
159
+ static get observedAttributes() {
160
+ return [
161
+ "publishable-key",
162
+ "api-base-url",
163
+ "locale",
164
+ "queue",
165
+ "label",
166
+ "show-status",
167
+ "show-mute",
168
+ "branding",
169
+ "debug"
170
+ ];
171
+ }
172
+ #status = "idle";
173
+ #callId = null;
174
+ #muted = false;
175
+ #error = null;
176
+ #locale = "en";
177
+ #busy = false;
178
+ #call = null;
179
+ #unbind = null;
180
+ #root = null;
181
+ #styleEl = null;
182
+ #initialized = false;
183
+ #attrInitKey = null;
184
+ get uiState() {
185
+ return toUiState(this.#status);
186
+ }
187
+ get callStatus() {
188
+ return this.#status;
189
+ }
190
+ get callId() {
191
+ return this.#callId;
192
+ }
193
+ get muted() {
194
+ return this.#muted;
195
+ }
196
+ get error() {
197
+ return this.#error;
198
+ }
199
+ connectedCallback() {
200
+ this.#ensureDom();
201
+ this.#syncFromAttributes(
202
+ /* hangUpOnEndpointChange */
203
+ false
204
+ );
205
+ this.#render();
206
+ }
207
+ disconnectedCallback() {
208
+ this.#hangUpActive();
209
+ }
210
+ attributeChangedCallback(name, oldValue, newValue) {
211
+ if (oldValue === newValue) return;
212
+ if (!this.isConnected) return;
213
+ const endpointChanged = name === "publishable-key" || name === "api-base-url";
214
+ this.#syncFromAttributes(endpointChanged);
215
+ this.#render();
216
+ }
217
+ /** Start a queue call (customer-service ring → agent answers). */
218
+ async start() {
219
+ return this.#start();
220
+ }
221
+ /** Hang up the active call (idempotent). */
222
+ async end() {
223
+ return this.#end();
224
+ }
225
+ async setMuted(muted) {
226
+ const call = this.#call ?? CallEmbed.getActiveCall();
227
+ if (!call) {
228
+ this.#muted = muted;
229
+ this.#emitState();
230
+ this.#render();
231
+ return;
232
+ }
233
+ await call.setMuted(muted);
234
+ }
235
+ #ensureDom() {
236
+ if (this.#root) return;
237
+ this.#styleEl = document.createElement("style");
238
+ this.#styleEl.textContent = ELEMENT_STYLES;
239
+ this.appendChild(this.#styleEl);
240
+ this.#root = document.createElement("div");
241
+ this.#root.className = "ce-el-root";
242
+ this.#root.setAttribute("data-ce-el", "");
243
+ this.appendChild(this.#root);
244
+ }
245
+ #syncFromAttributes(hangUpOnEndpointChange) {
246
+ const publishableKey = this.getAttribute("publishable-key") ?? "";
247
+ const apiBaseUrl = this.getAttribute("api-base-url");
248
+ const locale = parseLocale(this.getAttribute("locale"));
249
+ const brandingAttr = this.getAttribute("branding");
250
+ const branding = brandingAttr !== "false";
251
+ const debug = this.getAttribute("debug") === "true";
252
+ const initKey = JSON.stringify({
253
+ publishableKey,
254
+ apiBaseUrl: apiBaseUrl ?? "",
255
+ locale: locale ?? null,
256
+ branding,
257
+ debug
258
+ });
259
+ if (hangUpOnEndpointChange && this.#attrInitKey !== null) {
260
+ const prev = JSON.parse(this.#attrInitKey);
261
+ if (prev.publishableKey !== publishableKey || prev.apiBaseUrl !== (apiBaseUrl ?? "")) {
262
+ this.#hangUpActive();
263
+ }
264
+ }
265
+ if (initKey === this.#attrInitKey && this.#initialized) {
266
+ return;
267
+ }
268
+ this.#attrInitKey = initKey;
269
+ if (!publishableKey || apiBaseUrl === null) {
270
+ return;
271
+ }
272
+ try {
273
+ CallEmbed.init({
274
+ publishableKey,
275
+ apiBaseUrl,
276
+ locale,
277
+ branding,
278
+ debug
279
+ });
280
+ const runtime = CallEmbed._getRuntime();
281
+ if (runtime) this.#locale = runtime.locale;
282
+ this.#initialized = true;
283
+ } catch (err) {
284
+ const state = toErrorState(err);
285
+ this.#error = state;
286
+ this.#status = "error";
287
+ this.#emitError(state);
288
+ this.#emitState();
289
+ }
290
+ }
291
+ #hangUpActive() {
292
+ this.#unbind?.();
293
+ this.#unbind = null;
294
+ const call = this.#call ?? CallEmbed.getActiveCall();
295
+ this.#call = null;
296
+ if (call && isActiveStatus(call.status)) {
297
+ void call.hangUp();
298
+ }
299
+ }
300
+ async #start() {
301
+ try {
302
+ const queue = this.getAttribute("queue") ?? "";
303
+ if (!queue.trim()) {
304
+ throw new CallEmbedError(
305
+ "invalid_queue",
306
+ "queue is required (set the queue attribute)."
307
+ );
308
+ }
309
+ this.#error = null;
310
+ this.#muted = false;
311
+ const call = await CallEmbed.startCall({
312
+ queue,
313
+ hidePanel: true,
314
+ on: {
315
+ statusChange: ({ status }) => {
316
+ this.#status = status;
317
+ this.#emitState();
318
+ this.#render();
319
+ },
320
+ muteChange: ({ muted }) => {
321
+ this.#muted = muted;
322
+ this.#emitState();
323
+ this.#render();
324
+ },
325
+ error: ({ code, message, callId }) => {
326
+ this.#error = { code, message };
327
+ this.#status = "error";
328
+ if (callId && callId !== "pending") this.#callId = callId;
329
+ this.#emitError(this.#error);
330
+ this.#emitState();
331
+ this.#render();
332
+ },
333
+ ended: ({ callId }) => {
334
+ this.#status = "ended";
335
+ if (callId && callId !== "pending") this.#callId = callId;
336
+ this.#emitState();
337
+ this.#render();
338
+ },
339
+ ringing: ({ callId }) => {
340
+ this.#callId = callId;
341
+ this.#emitState();
342
+ },
343
+ answered: ({ callId }) => {
344
+ this.#callId = callId;
345
+ this.#emitState();
346
+ }
347
+ }
348
+ });
349
+ this.#unbind?.();
350
+ this.#call = call;
351
+ this.#status = call.status;
352
+ if (call.callId !== "pending") this.#callId = call.callId;
353
+ this.#unbind = this.#bindCall(call);
354
+ this.#emitState();
355
+ this.#render();
356
+ return call;
357
+ } catch (err) {
358
+ const state = toErrorState(err);
359
+ this.#error = state;
360
+ this.#status = "error";
361
+ this.#emitError(state);
362
+ this.#emitState();
363
+ this.#render();
364
+ if (err instanceof CallEmbedError) throw err;
365
+ throw new CallEmbedError("call_failed", state.message);
366
+ }
367
+ }
368
+ async #end() {
369
+ const call = this.#call ?? CallEmbed.getActiveCall();
370
+ if (!call) {
371
+ this.#status = "idle";
372
+ this.#emitState();
373
+ this.#render();
374
+ return;
375
+ }
376
+ await call.hangUp();
377
+ }
378
+ #bindCall(call) {
379
+ const offs = [
380
+ call.on("statusChange", ({ status }) => {
381
+ this.#status = status;
382
+ if (call.callId && call.callId !== "pending") {
383
+ this.#callId = call.callId;
384
+ }
385
+ this.#emitState();
386
+ this.#render();
387
+ }),
388
+ call.on("muteChange", ({ muted }) => {
389
+ this.#muted = muted;
390
+ this.#emitState();
391
+ this.#render();
392
+ }),
393
+ call.on("error", ({ code, message, callId }) => {
394
+ this.#error = { code, message };
395
+ this.#status = "error";
396
+ if (callId && callId !== "pending") this.#callId = callId;
397
+ this.#emitError(this.#error);
398
+ this.#emitState();
399
+ this.#render();
400
+ }),
401
+ call.on("ended", ({ callId }) => {
402
+ this.#status = "ended";
403
+ if (callId && callId !== "pending") this.#callId = callId;
404
+ this.#emitState();
405
+ this.#render();
406
+ })
407
+ ];
408
+ return () => {
409
+ offs.forEach((off) => off());
410
+ };
411
+ }
412
+ #emitState() {
413
+ this.dispatchEvent(
414
+ new CustomEvent("callembed:state", {
415
+ bubbles: true,
416
+ composed: true,
417
+ detail: {
418
+ uiState: this.uiState,
419
+ status: this.#status,
420
+ callId: this.#callId,
421
+ muted: this.#muted,
422
+ error: this.#error
423
+ }
424
+ })
425
+ );
426
+ }
427
+ #emitError(error) {
428
+ this.dispatchEvent(
429
+ new CustomEvent("callembed:error", {
430
+ bubbles: true,
431
+ composed: true,
432
+ detail: { code: error.code, message: error.message }
433
+ })
434
+ );
435
+ }
436
+ #showStatus() {
437
+ const v = this.getAttribute("show-status");
438
+ return v !== "false";
439
+ }
440
+ #showMute() {
441
+ const v = this.getAttribute("show-mute");
442
+ return v !== "false";
443
+ }
444
+ #render() {
445
+ if (!this.#root) return;
446
+ const copy = resolveCopy(runtimeForCopy(this.#locale));
447
+ const ui = this.uiState;
448
+ const inCall = ui === "ringing" || ui === "connected";
449
+ const labelAttr = this.getAttribute("label");
450
+ const buttonLabel = inCall ? copy.hangup : labelAttr ?? copy.launchDefault;
451
+ const statusText = ui === "error" && this.#error ? friendlyErrorMessage(copy, this.#error.code, this.#error.message) : statusLabel(copy, ui, this.#status);
452
+ const tone = ui === "error" ? "error" : ui === "connected" ? "ok" : void 0;
453
+ this.#root.replaceChildren();
454
+ const btn = document.createElement("button");
455
+ btn.type = "button";
456
+ btn.className = "ce-el-button";
457
+ btn.dataset.state = ui;
458
+ btn.disabled = this.#busy;
459
+ if (this.#busy) btn.setAttribute("aria-busy", "true");
460
+ btn.textContent = buttonLabel;
461
+ btn.addEventListener("click", () => {
462
+ void this.#onMainClick();
463
+ });
464
+ this.#root.appendChild(btn);
465
+ if (this.#showMute() && inCall) {
466
+ const muteBtn = document.createElement("button");
467
+ muteBtn.type = "button";
468
+ muteBtn.className = "ce-el-mute";
469
+ muteBtn.disabled = this.#busy;
470
+ muteBtn.textContent = this.#muted ? copy.unmute : copy.mute;
471
+ muteBtn.addEventListener("click", () => {
472
+ void this.#onMuteClick();
473
+ });
474
+ this.#root.appendChild(muteBtn);
475
+ }
476
+ if (this.#showStatus()) {
477
+ const statusEl = document.createElement("p");
478
+ statusEl.className = "ce-el-status";
479
+ statusEl.dataset.state = ui;
480
+ if (tone) statusEl.dataset.tone = tone;
481
+ statusEl.textContent = statusText;
482
+ this.#root.appendChild(statusEl);
483
+ }
484
+ }
485
+ async #onMainClick() {
486
+ if (this.#busy) return;
487
+ this.#busy = true;
488
+ this.#render();
489
+ try {
490
+ const ui = this.uiState;
491
+ if (ui === "ringing" || ui === "connected") {
492
+ await this.#end();
493
+ } else {
494
+ await this.#start();
495
+ }
496
+ } catch {
497
+ } finally {
498
+ this.#busy = false;
499
+ this.#render();
500
+ }
501
+ }
502
+ async #onMuteClick() {
503
+ if (this.#busy) return;
504
+ this.#busy = true;
505
+ this.#render();
506
+ try {
507
+ await this.setMuted(!this.#muted);
508
+ } catch {
509
+ } finally {
510
+ this.#busy = false;
511
+ this.#render();
512
+ }
513
+ }
514
+ };
515
+ function defineCallEmbedButton(tagName = TAG) {
516
+ if (typeof customElements === "undefined") {
517
+ return CallEmbedButtonElement;
518
+ }
519
+ if (!customElements.get(tagName)) {
520
+ customElements.define(tagName, CallEmbedButtonElement);
521
+ }
522
+ return CallEmbedButtonElement;
523
+ }
524
+
525
+ // src/index.ts
526
+ defineCallEmbedButton();
527
+
528
+ export { TAG as CALLEMBED_BUTTON_TAG, CallEmbedButtonElement, defineCallEmbedButton, statusLabel, toUiState };
package/package.json CHANGED
@@ -1,6 +1,83 @@
1
1
  {
2
2
  "name": "@callembed/elements",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Framework-agnostic CallEmbed Web Component (thin wrapper over @callembed/sdk)",
5
+ "license": "MIT",
6
+ "author": "Sealby",
7
+ "type": "module",
8
+ "main": "./dist/index.cjs",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "unpkg": "./dist/callembed-elements.iife.js",
12
+ "jsdelivr": "./dist/callembed-elements.iife.js",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js",
17
+ "require": "./dist/index.cjs",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "sideEffects": [
23
+ "./dist/index.js",
24
+ "./dist/index.cjs",
25
+ "./dist/callembed-elements.iife.js"
26
+ ],
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsup && node ./scripts/build-iife.mjs",
34
+ "prepack": "npm run build",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit",
36
+ "dev": "tsup --watch",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "pack:verify": "node ./scripts/pack-verify.mjs"
40
+ },
41
+ "homepage": "https://callembed.sealby.me",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/TonyFeng1203/CallEmbed.git",
45
+ "directory": "packages/elements"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/TonyFeng1203/CallEmbed/issues"
49
+ },
50
+ "keywords": [
51
+ "callembed",
52
+ "web-components",
53
+ "custom-elements",
54
+ "voice",
55
+ "webrtc",
56
+ "livekit",
57
+ "customer-support"
58
+ ],
59
+ "engines": {
60
+ "node": ">=20"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "peerDependencies": {
66
+ "@callembed/sdk": "^0.1.0",
67
+ "livekit-client": "^2.13.0"
68
+ },
69
+ "peerDependenciesMeta": {
70
+ "livekit-client": {
71
+ "optional": true
72
+ }
73
+ },
74
+ "devDependencies": {
75
+ "@callembed/sdk": "^0.1.0",
76
+ "esbuild": "0.25.4",
77
+ "jsdom": "26.1.0",
78
+ "livekit-client": "^2.13.0",
79
+ "tsup": "^8.4.0",
80
+ "typescript": "^5.8.3",
81
+ "vitest": "3.2.7"
82
+ }
83
+ }