@sunboard/react 1.1.0 → 2.0.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 +45 -25
- package/dist/index.d.mts +289 -22
- package/dist/index.mjs +1185 -133
- package/dist/styles.css +251 -0
- package/package.json +2 -1
package/dist/index.mjs
CHANGED
|
@@ -193,7 +193,9 @@ const defaultTheme = createTheme("Default", {
|
|
|
193
193
|
const defaultComponentSettings = {
|
|
194
194
|
launcher: {
|
|
195
195
|
showBranding: true,
|
|
196
|
-
supportUrl: null
|
|
196
|
+
supportUrl: null,
|
|
197
|
+
talkToPerson: false,
|
|
198
|
+
replyTime: "We reply within a few hours."
|
|
197
199
|
},
|
|
198
200
|
tour: {
|
|
199
201
|
showStepCount: true,
|
|
@@ -226,7 +228,7 @@ const enLabels = {
|
|
|
226
228
|
rateTour: "Rate this tour",
|
|
227
229
|
stars: (count) => `${count} ${count === 1 ? "star" : "stars"}`,
|
|
228
230
|
ratingThanks: "Thanks for the feedback.",
|
|
229
|
-
|
|
231
|
+
thread: "Conversation",
|
|
230
232
|
writingStatus: "Writing an answer",
|
|
231
233
|
stages: {
|
|
232
234
|
searching: (product) => `Searching the ${product} help docs`,
|
|
@@ -249,6 +251,36 @@ const enLabels = {
|
|
|
249
251
|
closeAria: "Close help",
|
|
250
252
|
poweredBy: "Powered by"
|
|
251
253
|
},
|
|
254
|
+
support: {
|
|
255
|
+
talkToPerson: "Talk to a person",
|
|
256
|
+
title: (company) => `${company} support`,
|
|
257
|
+
back: "Back",
|
|
258
|
+
talking: (company) => `Talking to ${company} support`,
|
|
259
|
+
greeting: (name) => `Hey ${name ?? "there"}, what can we help you with?`,
|
|
260
|
+
from: (company) => `${company} support`,
|
|
261
|
+
boxLabel: (company) => `Write to ${company} support`,
|
|
262
|
+
placeholder: (company) => `Write to ${company} support…`,
|
|
263
|
+
teamMessage: (who, company, time) => [
|
|
264
|
+
who,
|
|
265
|
+
`${company} support`,
|
|
266
|
+
time
|
|
267
|
+
].filter(Boolean).join(" · "),
|
|
268
|
+
asked: (question) => `Asked the assistant: ${question}`,
|
|
269
|
+
closed: (company) => `${company} support closed this conversation`,
|
|
270
|
+
reopen: "Write again to reopen it.",
|
|
271
|
+
conversationWith: (company) => `Conversation with ${company} support`,
|
|
272
|
+
replied: (who) => `${who} replied`,
|
|
273
|
+
waiting: "Waiting for a reply",
|
|
274
|
+
newReply: "New reply",
|
|
275
|
+
openAriaNewReply: "Open help, new reply",
|
|
276
|
+
past: "Past conversations",
|
|
277
|
+
closedWhen: (when, who) => who ? `Closed ${when} · ${who}` : `Closed ${when}`,
|
|
278
|
+
ago: (days) => days < 1 ? "today" : days < 2 ? "yesterday" : days < 14 ? `${days} days ago` : days < 60 ? `${Math.round(days / 7)} weeks ago` : `${Math.round(days / 30)} months ago`,
|
|
279
|
+
tooMany: "Too many messages this hour",
|
|
280
|
+
writeAgainIn: (minutes) => `You can write again in about ${minutes} ${minutes === 1 ? "minute" : "minutes"}.`,
|
|
281
|
+
lost: "Could not send your message",
|
|
282
|
+
lostBody: "Check your connection and try again."
|
|
283
|
+
},
|
|
252
284
|
tour: {
|
|
253
285
|
back: "Back",
|
|
254
286
|
next: "Next",
|
|
@@ -278,6 +310,10 @@ function withLabels(overrides) {
|
|
|
278
310
|
...enLabels.launcher,
|
|
279
311
|
...overrides.launcher
|
|
280
312
|
},
|
|
313
|
+
support: {
|
|
314
|
+
...enLabels.support,
|
|
315
|
+
...overrides.support
|
|
316
|
+
},
|
|
281
317
|
tour: {
|
|
282
318
|
...enLabels.tour,
|
|
283
319
|
...overrides.tour
|
|
@@ -377,6 +413,8 @@ const TOUR_RATINGS = [
|
|
|
377
413
|
4,
|
|
378
414
|
5
|
|
379
415
|
];
|
|
416
|
+
/** One home for the cap, so the composer and the route agree on it. */
|
|
417
|
+
const CONVERSATION_MESSAGE_MAX_LENGTH = 4e3;
|
|
380
418
|
//#endregion
|
|
381
419
|
//#region src/active-tour.ts
|
|
382
420
|
const STORAGE_KEY = "sunboard:active-tour";
|
|
@@ -413,7 +451,7 @@ function stepInRange(guides, key, stepIndex) {
|
|
|
413
451
|
const PREFIX = "sunboard:bootstrap:";
|
|
414
452
|
/** Past this, content the server has since changed is likelier than not. */
|
|
415
453
|
const MAX_AGE_MS = 6048e5;
|
|
416
|
-
const VERSION =
|
|
454
|
+
const VERSION = 5;
|
|
417
455
|
function readCachedBootstrap(key) {
|
|
418
456
|
if (typeof window === "undefined") return null;
|
|
419
457
|
try {
|
|
@@ -1350,15 +1388,221 @@ function useThemeMode(setting) {
|
|
|
1350
1388
|
return setting === "auto" ? detected : setting;
|
|
1351
1389
|
}
|
|
1352
1390
|
//#endregion
|
|
1391
|
+
//#region src/memory-support.ts
|
|
1392
|
+
let counter = 0;
|
|
1393
|
+
const nextId = (kind) => `memory-${kind}-${++counter}`;
|
|
1394
|
+
var MemorySupport = class {
|
|
1395
|
+
script;
|
|
1396
|
+
starts = [];
|
|
1397
|
+
sent = [];
|
|
1398
|
+
reads = [];
|
|
1399
|
+
polls = 0;
|
|
1400
|
+
views = 0;
|
|
1401
|
+
conversations;
|
|
1402
|
+
constructor(script) {
|
|
1403
|
+
this.script = script;
|
|
1404
|
+
this.conversations = (script.conversations ?? []).map(stored);
|
|
1405
|
+
}
|
|
1406
|
+
conversationPoll() {
|
|
1407
|
+
this.polls += 1;
|
|
1408
|
+
const open = this.open();
|
|
1409
|
+
return Promise.resolve({
|
|
1410
|
+
open: open ? summary(open) : null,
|
|
1411
|
+
hasPast: this.conversations.some((conversation) => conversation.view.closed)
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
conversation(id, after) {
|
|
1415
|
+
this.views += 1;
|
|
1416
|
+
const found = this.find(id);
|
|
1417
|
+
if (!found) return Promise.resolve(null);
|
|
1418
|
+
const view = structuredClone(found.view);
|
|
1419
|
+
const at = after === void 0 ? -1 : view.messages.findIndex((message) => message.id === after);
|
|
1420
|
+
return Promise.resolve({
|
|
1421
|
+
...view,
|
|
1422
|
+
messages: view.messages.slice(at + 1)
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
startConversation(start) {
|
|
1426
|
+
this.starts.push(start);
|
|
1427
|
+
if (this.script.refuse) return Promise.resolve({ refused: this.script.refuse });
|
|
1428
|
+
let conversation = this.open();
|
|
1429
|
+
if (!conversation) {
|
|
1430
|
+
conversation = stored({ messages: [] });
|
|
1431
|
+
this.conversations.unshift(conversation);
|
|
1432
|
+
}
|
|
1433
|
+
return Promise.resolve(this.write(conversation, start.text));
|
|
1434
|
+
}
|
|
1435
|
+
sendMessage(id, text) {
|
|
1436
|
+
this.sent.push({
|
|
1437
|
+
id,
|
|
1438
|
+
text
|
|
1439
|
+
});
|
|
1440
|
+
if (this.script.refuse) return Promise.resolve({ refused: this.script.refuse });
|
|
1441
|
+
const conversation = this.find(id);
|
|
1442
|
+
if (!conversation) return Promise.resolve({ refused: "lost" });
|
|
1443
|
+
return Promise.resolve(this.write(conversation, text));
|
|
1444
|
+
}
|
|
1445
|
+
markRead(id) {
|
|
1446
|
+
this.reads.push(id);
|
|
1447
|
+
const conversation = this.find(id);
|
|
1448
|
+
if (conversation) conversation.unread = false;
|
|
1449
|
+
return Promise.resolve();
|
|
1450
|
+
}
|
|
1451
|
+
pastConversations() {
|
|
1452
|
+
const closed = this.conversations.filter((conversation) => conversation.view.closed).sort((a, b) => (b.view.closedAt ?? "").localeCompare(a.view.closedAt ?? ""));
|
|
1453
|
+
return Promise.resolve(closed.map((conversation) => ({
|
|
1454
|
+
id: conversation.view.id,
|
|
1455
|
+
title: conversation.view.messages.find((message) => message.by === "END_USER")?.text ?? "",
|
|
1456
|
+
closedAt: conversation.view.closedAt,
|
|
1457
|
+
closedBy: conversation.closedBy
|
|
1458
|
+
})));
|
|
1459
|
+
}
|
|
1460
|
+
/** The team writes into the open conversation. */
|
|
1461
|
+
teamReplies(text, who) {
|
|
1462
|
+
const conversation = this.open();
|
|
1463
|
+
if (!conversation) return;
|
|
1464
|
+
conversation.view.messages.push(message("TEAM", text, who));
|
|
1465
|
+
conversation.view.status = "PENDING";
|
|
1466
|
+
conversation.unread = true;
|
|
1467
|
+
}
|
|
1468
|
+
/** The team closes the open conversation. */
|
|
1469
|
+
teamCloses(who) {
|
|
1470
|
+
const conversation = this.open();
|
|
1471
|
+
if (!conversation) return;
|
|
1472
|
+
conversation.view.status = "CLOSED";
|
|
1473
|
+
conversation.view.closed = true;
|
|
1474
|
+
conversation.view.closedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1475
|
+
conversation.closedBy = who;
|
|
1476
|
+
}
|
|
1477
|
+
write(conversation, text) {
|
|
1478
|
+
const sent = message("END_USER", text);
|
|
1479
|
+
const first = conversation.view.messages.length === 0;
|
|
1480
|
+
conversation.view.messages.push(sent);
|
|
1481
|
+
conversation.view.status = "OPEN";
|
|
1482
|
+
conversation.view.closed = false;
|
|
1483
|
+
conversation.view.closedAt = null;
|
|
1484
|
+
conversation.closedBy = null;
|
|
1485
|
+
const { reply } = this.script;
|
|
1486
|
+
if (first && reply) setTimeout(() => this.teamReplies(reply.text, reply.who), reply.afterMs);
|
|
1487
|
+
return {
|
|
1488
|
+
summary: {
|
|
1489
|
+
...summary(conversation),
|
|
1490
|
+
unread: false
|
|
1491
|
+
},
|
|
1492
|
+
message: sent
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
open() {
|
|
1496
|
+
return this.conversations.find((conversation) => !conversation.view.closed);
|
|
1497
|
+
}
|
|
1498
|
+
find(id) {
|
|
1499
|
+
return this.conversations.find((conversation) => conversation.view.id === id);
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
function message(by, text, who) {
|
|
1503
|
+
return {
|
|
1504
|
+
id: nextId("message"),
|
|
1505
|
+
by,
|
|
1506
|
+
who: who ?? null,
|
|
1507
|
+
text,
|
|
1508
|
+
sentAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
function folded(questions) {
|
|
1512
|
+
return (questions ?? []).map((question) => ({
|
|
1513
|
+
...question,
|
|
1514
|
+
id: nextId("question"),
|
|
1515
|
+
outcome: "ANSWERED"
|
|
1516
|
+
}));
|
|
1517
|
+
}
|
|
1518
|
+
function stored(script) {
|
|
1519
|
+
const messages = script.messages.map((scripted) => ({
|
|
1520
|
+
id: nextId("message"),
|
|
1521
|
+
by: scripted.by,
|
|
1522
|
+
who: scripted.who ?? null,
|
|
1523
|
+
text: scripted.text,
|
|
1524
|
+
sentAt: scripted.sentAt
|
|
1525
|
+
}));
|
|
1526
|
+
const closed = script.closedAt !== void 0;
|
|
1527
|
+
return {
|
|
1528
|
+
view: {
|
|
1529
|
+
id: nextId("conversation"),
|
|
1530
|
+
status: closed ? "CLOSED" : messages.at(-1)?.by === "TEAM" ? "PENDING" : "OPEN",
|
|
1531
|
+
startedAt: messages[0]?.sentAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1532
|
+
closedAt: script.closedAt ?? null,
|
|
1533
|
+
closed,
|
|
1534
|
+
messages,
|
|
1535
|
+
before: folded(script.before),
|
|
1536
|
+
during: folded(script.during)
|
|
1537
|
+
},
|
|
1538
|
+
unread: script.unread ?? false,
|
|
1539
|
+
closedBy: script.closedBy ?? null
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
function summary({ view, unread }) {
|
|
1543
|
+
const last = view.messages.at(-1);
|
|
1544
|
+
return {
|
|
1545
|
+
id: view.id,
|
|
1546
|
+
status: view.status,
|
|
1547
|
+
lastMessageAt: last?.sentAt ?? view.startedAt,
|
|
1548
|
+
lastMessageBy: last?.by ?? "END_USER",
|
|
1549
|
+
lastMessageText: last?.text ?? "",
|
|
1550
|
+
lastMessageWho: last?.by === "TEAM" ? last.who ?? null : null,
|
|
1551
|
+
unread
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
//#endregion
|
|
1555
|
+
//#region src/user-token.ts
|
|
1556
|
+
const NOT_A_TOKEN = "Sunboard: userToken is not a user token. Make it on your server with your project's identity secret.";
|
|
1557
|
+
function decodeSegment(segment) {
|
|
1558
|
+
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
1559
|
+
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
|
1560
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Reads the token's claims without checking its signature, which only the runtime can do. Throws
|
|
1564
|
+
* on anything that is not a token with a subject.
|
|
1565
|
+
*/
|
|
1566
|
+
function readUserToken(token) {
|
|
1567
|
+
const [, body, signature, extra] = token.split(".");
|
|
1568
|
+
if (body === void 0 || signature === void 0 || extra !== void 0) throw new Error(NOT_A_TOKEN);
|
|
1569
|
+
let payload;
|
|
1570
|
+
try {
|
|
1571
|
+
payload = decodeSegment(body);
|
|
1572
|
+
} catch {
|
|
1573
|
+
throw new Error(NOT_A_TOKEN);
|
|
1574
|
+
}
|
|
1575
|
+
if (typeof payload !== "object" || payload === null) throw new Error(NOT_A_TOKEN);
|
|
1576
|
+
const sub = "sub" in payload ? payload.sub : void 0;
|
|
1577
|
+
const name = "name" in payload ? payload.name : void 0;
|
|
1578
|
+
if (typeof sub !== "string" || sub === "") throw new Error(NOT_A_TOKEN);
|
|
1579
|
+
return typeof name === "string" ? {
|
|
1580
|
+
sub,
|
|
1581
|
+
name
|
|
1582
|
+
} : { sub };
|
|
1583
|
+
}
|
|
1584
|
+
//#endregion
|
|
1353
1585
|
//#region src/transport.ts
|
|
1354
1586
|
/** Long enough for a slow model, short enough that nobody stares at nothing. */
|
|
1355
1587
|
const ASK_TIMEOUT_MS = 3e4;
|
|
1356
|
-
let
|
|
1588
|
+
let memoryThreads = 0;
|
|
1357
1589
|
const STAGES$1 = [
|
|
1358
1590
|
"searching",
|
|
1359
1591
|
"writing",
|
|
1360
1592
|
"checking"
|
|
1361
1593
|
];
|
|
1594
|
+
/** Sunboard switched off: what a connection whose token cannot be read gets, so the host's page stays up. */
|
|
1595
|
+
function offTransport() {
|
|
1596
|
+
const nothing = () => Promise.resolve();
|
|
1597
|
+
return {
|
|
1598
|
+
bootstrap: () => Promise.resolve({ off: true }),
|
|
1599
|
+
mutate: nothing,
|
|
1600
|
+
reportAnchorMiss: nothing,
|
|
1601
|
+
reportAnchorFound: nothing,
|
|
1602
|
+
stepCheck: nothing,
|
|
1603
|
+
recordEvents: nothing
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1362
1606
|
var MemoryTransport = class {
|
|
1363
1607
|
data;
|
|
1364
1608
|
misses = [];
|
|
@@ -1372,10 +1616,12 @@ var MemoryTransport = class {
|
|
|
1372
1616
|
user;
|
|
1373
1617
|
ask;
|
|
1374
1618
|
reportPage;
|
|
1619
|
+
support;
|
|
1375
1620
|
constructor(data, options) {
|
|
1376
1621
|
this.data = data;
|
|
1377
1622
|
this.cacheKey = options?.cacheKey;
|
|
1378
1623
|
this.user = options?.user;
|
|
1624
|
+
if (options?.support) this.support = new MemorySupport(options.support);
|
|
1379
1625
|
if (options?.census) this.reportPage = (report) => {
|
|
1380
1626
|
this.pages.push(report);
|
|
1381
1627
|
return Promise.resolve();
|
|
@@ -1383,7 +1629,7 @@ var MemoryTransport = class {
|
|
|
1383
1629
|
const script = options?.ask;
|
|
1384
1630
|
if (!script) return;
|
|
1385
1631
|
const gapMs = options?.gapMs ?? 0;
|
|
1386
|
-
const
|
|
1632
|
+
const threadId = `memory-thread-${++memoryThreads}`;
|
|
1387
1633
|
this.ask = async (asked, signal, onStage) => {
|
|
1388
1634
|
this.questions.push(asked);
|
|
1389
1635
|
const scripted = script(asked);
|
|
@@ -1404,8 +1650,8 @@ var MemoryTransport = class {
|
|
|
1404
1650
|
kind: "answer",
|
|
1405
1651
|
reply: {
|
|
1406
1652
|
...scripted.reply,
|
|
1407
|
-
|
|
1408
|
-
firstTurn: scripted.reply.firstTurn ?? asked.
|
|
1653
|
+
threadId: scripted.reply.threadId ?? threadId,
|
|
1654
|
+
firstTurn: scripted.reply.firstTurn ?? asked.threadId === void 0
|
|
1409
1655
|
}
|
|
1410
1656
|
};
|
|
1411
1657
|
};
|
|
@@ -1516,89 +1762,114 @@ var HttpTransport = class {
|
|
|
1516
1762
|
previewKey;
|
|
1517
1763
|
cacheKey;
|
|
1518
1764
|
user;
|
|
1765
|
+
support;
|
|
1519
1766
|
constructor(config) {
|
|
1520
1767
|
this.config = config;
|
|
1768
|
+
const { sub, name } = readUserToken(config.userToken);
|
|
1521
1769
|
this.baseUrl = config.baseUrl ?? "https://api.sunboardhq.com";
|
|
1522
|
-
this.user =
|
|
1770
|
+
this.user = name === void 0 ? {} : { firstName: name };
|
|
1523
1771
|
this.previewKey = config.previewKey ?? storedPreviewKey();
|
|
1524
|
-
this.cacheKey = this.previewKey ? void 0 : `${config.publishableKey}:${
|
|
1772
|
+
this.cacheKey = this.previewKey ? void 0 : `${config.publishableKey}:${sub}`;
|
|
1773
|
+
this.support = {
|
|
1774
|
+
conversationPoll: () => this.getJson("/runtime/conversation"),
|
|
1775
|
+
conversation: async (id, after) => {
|
|
1776
|
+
const response = await this.send(`/runtime/conversation/${encodeURIComponent(id)}?${this.identity(after ? { after } : {})}`);
|
|
1777
|
+
if (response.status === 404) return null;
|
|
1778
|
+
return await ok(response, "/runtime/conversation/:id").json();
|
|
1779
|
+
},
|
|
1780
|
+
startConversation: ({ threadId, path, text }) => this.write("/runtime/conversation", {
|
|
1781
|
+
threadId,
|
|
1782
|
+
path,
|
|
1783
|
+
text
|
|
1784
|
+
}),
|
|
1785
|
+
sendMessage: (id, text) => this.write(`/runtime/conversation/${encodeURIComponent(id)}/messages`, { text }),
|
|
1786
|
+
markRead: async (id) => {
|
|
1787
|
+
await this.post(`/runtime/conversation/${encodeURIComponent(id)}/read`, {});
|
|
1788
|
+
},
|
|
1789
|
+
pastConversations: () => this.getJson("/runtime/conversations")
|
|
1790
|
+
};
|
|
1525
1791
|
}
|
|
1526
|
-
|
|
1792
|
+
identity(extra = {}) {
|
|
1793
|
+
return new URLSearchParams({
|
|
1794
|
+
userToken: this.config.userToken,
|
|
1795
|
+
...extra
|
|
1796
|
+
}).toString();
|
|
1797
|
+
}
|
|
1798
|
+
signed(body) {
|
|
1527
1799
|
return {
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
...this.previewKey ? { "x-sunboard-preview-key": this.previewKey } : {}
|
|
1800
|
+
...body,
|
|
1801
|
+
userToken: this.config.userToken
|
|
1531
1802
|
};
|
|
1532
1803
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1804
|
+
send(path, init) {
|
|
1805
|
+
return fetch(`${this.baseUrl}${path}`, {
|
|
1535
1806
|
...init,
|
|
1536
1807
|
headers: {
|
|
1537
1808
|
...this.headers(),
|
|
1538
1809
|
...init?.headers
|
|
1539
1810
|
}
|
|
1540
1811
|
});
|
|
1541
|
-
if (!response.ok) throw new Error(`sunboard runtime request failed: ${response.status} for ${path}`);
|
|
1542
|
-
return response;
|
|
1543
1812
|
}
|
|
1544
|
-
async
|
|
1545
|
-
return await (await this.request(
|
|
1813
|
+
async getJson(path) {
|
|
1814
|
+
return await (await this.request(`${path}?${this.identity()}`)).json();
|
|
1546
1815
|
}
|
|
1547
|
-
|
|
1548
|
-
|
|
1816
|
+
post(path, body, init) {
|
|
1817
|
+
return this.request(path, {
|
|
1818
|
+
...init,
|
|
1549
1819
|
method: "POST",
|
|
1550
|
-
body: JSON.stringify(
|
|
1551
|
-
user: this.config.userId,
|
|
1552
|
-
...action
|
|
1553
|
-
})
|
|
1820
|
+
body: JSON.stringify(this.signed(body))
|
|
1554
1821
|
});
|
|
1555
1822
|
}
|
|
1556
|
-
async
|
|
1557
|
-
await this.
|
|
1823
|
+
async write(path, body) {
|
|
1824
|
+
const response = await this.send(path, {
|
|
1558
1825
|
method: "POST",
|
|
1559
|
-
body: JSON.stringify(
|
|
1560
|
-
user: this.config.userId,
|
|
1561
|
-
...report
|
|
1562
|
-
})
|
|
1826
|
+
body: JSON.stringify(this.signed(body))
|
|
1563
1827
|
});
|
|
1828
|
+
if (response.status === 429) return {
|
|
1829
|
+
refused: "too-many",
|
|
1830
|
+
retryAt: refusalIn(await response.text())?.retryAt
|
|
1831
|
+
};
|
|
1832
|
+
if (response.status === 409) return { refused: "another-open" };
|
|
1833
|
+
if (response.status === 404) return { refused: "lost" };
|
|
1834
|
+
return await ok(response, path).json();
|
|
1835
|
+
}
|
|
1836
|
+
headers() {
|
|
1837
|
+
return {
|
|
1838
|
+
"content-type": "application/json",
|
|
1839
|
+
"x-sunboard-key": this.config.publishableKey,
|
|
1840
|
+
...this.previewKey ? { "x-sunboard-preview-key": this.previewKey } : {}
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
async request(path, init) {
|
|
1844
|
+
return ok(await this.send(path, init), path);
|
|
1845
|
+
}
|
|
1846
|
+
async bootstrap() {
|
|
1847
|
+
return await (await this.request(`/runtime/bootstrap?${this.identity()}`)).json();
|
|
1848
|
+
}
|
|
1849
|
+
async mutate(action) {
|
|
1850
|
+
await this.post("/runtime/state", action);
|
|
1851
|
+
}
|
|
1852
|
+
async reportAnchorMiss(report) {
|
|
1853
|
+
await this.post("/runtime/anchor-miss", report);
|
|
1564
1854
|
}
|
|
1565
1855
|
async reportAnchorFound(report) {
|
|
1566
|
-
await this.
|
|
1567
|
-
method: "POST",
|
|
1568
|
-
body: JSON.stringify(report)
|
|
1569
|
-
});
|
|
1856
|
+
await this.post("/runtime/anchor-found", report);
|
|
1570
1857
|
}
|
|
1571
1858
|
async stepCheck(report) {
|
|
1572
|
-
await this.
|
|
1573
|
-
method: "POST",
|
|
1574
|
-
body: JSON.stringify(report)
|
|
1575
|
-
});
|
|
1859
|
+
await this.post("/runtime/step-check", report);
|
|
1576
1860
|
}
|
|
1577
1861
|
async recordEvents(events) {
|
|
1578
1862
|
if (events.length === 0) return;
|
|
1579
|
-
await this.
|
|
1580
|
-
method: "POST",
|
|
1581
|
-
body: JSON.stringify({
|
|
1582
|
-
user: this.config.userId,
|
|
1583
|
-
events
|
|
1584
|
-
}),
|
|
1585
|
-
keepalive: true
|
|
1586
|
-
});
|
|
1863
|
+
await this.post("/runtime/events", { events }, { keepalive: true });
|
|
1587
1864
|
}
|
|
1588
1865
|
async reportPage(report) {
|
|
1589
|
-
await this.
|
|
1590
|
-
method: "POST",
|
|
1591
|
-
body: JSON.stringify(report)
|
|
1592
|
-
});
|
|
1866
|
+
await this.post("/runtime/page", report);
|
|
1593
1867
|
}
|
|
1594
1868
|
async ask(asked, signal, onStage) {
|
|
1595
1869
|
const response = await fetch(`${this.baseUrl}/runtime/ask`, {
|
|
1596
1870
|
method: "POST",
|
|
1597
1871
|
headers: this.headers(),
|
|
1598
|
-
body: JSON.stringify(
|
|
1599
|
-
user: this.config.userId,
|
|
1600
|
-
...asked
|
|
1601
|
-
}),
|
|
1872
|
+
body: JSON.stringify(this.signed(asked)),
|
|
1602
1873
|
signal: AbortSignal.any([signal, AbortSignal.timeout(ASK_TIMEOUT_MS)])
|
|
1603
1874
|
});
|
|
1604
1875
|
if (response.status === 400 || response.status === 429) {
|
|
@@ -1615,6 +1886,10 @@ var HttpTransport = class {
|
|
|
1615
1886
|
};
|
|
1616
1887
|
}
|
|
1617
1888
|
};
|
|
1889
|
+
function ok(response, path) {
|
|
1890
|
+
if (!response.ok) throw new Error(`sunboard runtime request failed: ${response.status} for ${path}`);
|
|
1891
|
+
return response;
|
|
1892
|
+
}
|
|
1618
1893
|
/**
|
|
1619
1894
|
* The sentence a 400 or a 429 carries, and the time the cap lifts. Read as
|
|
1620
1895
|
* text and parsed loosely: a proxy in front of the API answers those codes
|
|
@@ -1652,14 +1927,14 @@ function optionalString(value) {
|
|
|
1652
1927
|
/** A reply as the server sends it, or as the thread kept it in session storage. */
|
|
1653
1928
|
function parseReply(value) {
|
|
1654
1929
|
if (!isRecord$1(value)) return void 0;
|
|
1655
|
-
const { id,
|
|
1656
|
-
if (typeof id !== "string" || typeof
|
|
1930
|
+
const { id, threadId, firstTurn, outcome, answer, guideKey, supportUrl } = value;
|
|
1931
|
+
if (typeof id !== "string" || typeof threadId !== "string") return void 0;
|
|
1657
1932
|
if (typeof firstTurn !== "boolean") return void 0;
|
|
1658
1933
|
if (!isAskOutcome(outcome) || typeof answer !== "string") return void 0;
|
|
1659
1934
|
if (!optionalString(guideKey) || !optionalString(supportUrl)) return void 0;
|
|
1660
1935
|
return {
|
|
1661
1936
|
id,
|
|
1662
|
-
|
|
1937
|
+
threadId,
|
|
1663
1938
|
firstTurn,
|
|
1664
1939
|
outcome,
|
|
1665
1940
|
answer,
|
|
@@ -1726,8 +2001,13 @@ function SunboardProvider(props) {
|
|
|
1726
2001
|
const connectionKey = connection ? JSON.stringify(connection) : null;
|
|
1727
2002
|
const resolvedTransport = useMemo(() => {
|
|
1728
2003
|
if (transport) return transport;
|
|
1729
|
-
if (connectionKey) return
|
|
1730
|
-
|
|
2004
|
+
if (!connectionKey) return null;
|
|
2005
|
+
try {
|
|
2006
|
+
return new HttpTransport(JSON.parse(connectionKey));
|
|
2007
|
+
} catch (error) {
|
|
2008
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
2009
|
+
return offTransport();
|
|
2010
|
+
}
|
|
1731
2011
|
}, [transport, connectionKey]);
|
|
1732
2012
|
if (!resolvedTransport) throw new Error("SunboardProvider needs either a connection or a transport");
|
|
1733
2013
|
const [answer, setAnswer] = useState(null);
|
|
@@ -1961,6 +2241,7 @@ function SunboardProvider(props) {
|
|
|
1961
2241
|
if (!transport.ask) throw new Error("this transport cannot ask questions");
|
|
1962
2242
|
return transport.ask(asked, signal, onStage);
|
|
1963
2243
|
},
|
|
2244
|
+
support: off ? void 0 : resolvedTransport.support,
|
|
1964
2245
|
track,
|
|
1965
2246
|
navigate
|
|
1966
2247
|
};
|
|
@@ -2099,7 +2380,7 @@ function threadReducer(thread, action) {
|
|
|
2099
2380
|
} : response);
|
|
2100
2381
|
return next === thread ? thread : {
|
|
2101
2382
|
...next,
|
|
2102
|
-
|
|
2383
|
+
threadId: action.reply.threadId
|
|
2103
2384
|
};
|
|
2104
2385
|
}
|
|
2105
2386
|
case "failed": return replaceResponse(thread, action.questionId, (response) => response.kind === "waiting" ? {
|
|
@@ -2279,14 +2560,14 @@ function parseThread(value) {
|
|
|
2279
2560
|
if (!message) return null;
|
|
2280
2561
|
messages.push(message);
|
|
2281
2562
|
}
|
|
2282
|
-
const
|
|
2563
|
+
const threadId = typeof value.threadId === "string" ? value.threadId : void 0;
|
|
2283
2564
|
const handoff = parseHandoff(value.handoff);
|
|
2284
|
-
return
|
|
2565
|
+
return threadId === void 0 ? {
|
|
2285
2566
|
messages,
|
|
2286
2567
|
handoff
|
|
2287
2568
|
} : {
|
|
2288
2569
|
messages,
|
|
2289
|
-
|
|
2570
|
+
threadId,
|
|
2290
2571
|
handoff
|
|
2291
2572
|
};
|
|
2292
2573
|
}
|
|
@@ -2375,6 +2656,162 @@ function parseFailure(value) {
|
|
|
2375
2656
|
return null;
|
|
2376
2657
|
}
|
|
2377
2658
|
//#endregion
|
|
2659
|
+
//#region src/support.ts
|
|
2660
|
+
const emptySupport = {
|
|
2661
|
+
off: false,
|
|
2662
|
+
open: null,
|
|
2663
|
+
hasPast: false,
|
|
2664
|
+
view: "assistant",
|
|
2665
|
+
conversation: null,
|
|
2666
|
+
readOnly: false,
|
|
2667
|
+
past: null,
|
|
2668
|
+
sending: false,
|
|
2669
|
+
failure: null
|
|
2670
|
+
};
|
|
2671
|
+
function supportReducer(state, action) {
|
|
2672
|
+
switch (action.type) {
|
|
2673
|
+
case "off": return {
|
|
2674
|
+
...emptySupport,
|
|
2675
|
+
off: true
|
|
2676
|
+
};
|
|
2677
|
+
case "polled": return {
|
|
2678
|
+
...state,
|
|
2679
|
+
open: action.poll.open,
|
|
2680
|
+
hasPast: action.poll.hasPast
|
|
2681
|
+
};
|
|
2682
|
+
case "talk": {
|
|
2683
|
+
const keep = state.conversation !== null && state.conversation.id === state.open?.id;
|
|
2684
|
+
return {
|
|
2685
|
+
...state,
|
|
2686
|
+
view: "conversation",
|
|
2687
|
+
readOnly: false,
|
|
2688
|
+
failure: null,
|
|
2689
|
+
conversation: keep ? state.conversation : null
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
case "loaded": {
|
|
2693
|
+
const loaded = action.conversation;
|
|
2694
|
+
if (!canWrite(state)) return state;
|
|
2695
|
+
if (state.conversation !== null && state.conversation.id !== loaded.id) return state;
|
|
2696
|
+
const conversation = merged(state.conversation, loaded);
|
|
2697
|
+
return {
|
|
2698
|
+
...state,
|
|
2699
|
+
conversation,
|
|
2700
|
+
...state.open?.id === loaded.id ? summarized(state, conversation) : {}
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
case "sending": return {
|
|
2704
|
+
...state,
|
|
2705
|
+
sending: true,
|
|
2706
|
+
failure: null
|
|
2707
|
+
};
|
|
2708
|
+
case "sent": {
|
|
2709
|
+
const { summary, message } = action.sent;
|
|
2710
|
+
const current = state.conversation?.id === summary.id ? state.conversation : null;
|
|
2711
|
+
const conversation = current ? {
|
|
2712
|
+
...current,
|
|
2713
|
+
status: summary.status,
|
|
2714
|
+
closed: false,
|
|
2715
|
+
closedAt: null,
|
|
2716
|
+
messages: withMessages(current.messages, [message])
|
|
2717
|
+
} : {
|
|
2718
|
+
id: summary.id,
|
|
2719
|
+
status: summary.status,
|
|
2720
|
+
startedAt: message.sentAt,
|
|
2721
|
+
closedAt: null,
|
|
2722
|
+
closed: false,
|
|
2723
|
+
messages: [message],
|
|
2724
|
+
before: [],
|
|
2725
|
+
during: []
|
|
2726
|
+
};
|
|
2727
|
+
return {
|
|
2728
|
+
...state,
|
|
2729
|
+
sending: false,
|
|
2730
|
+
open: summary,
|
|
2731
|
+
conversation
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
case "refused": return {
|
|
2735
|
+
...state,
|
|
2736
|
+
sending: false,
|
|
2737
|
+
failure: action.refused
|
|
2738
|
+
};
|
|
2739
|
+
case "read": return state.open?.id === action.id && state.open.unread ? {
|
|
2740
|
+
...state,
|
|
2741
|
+
open: {
|
|
2742
|
+
...state.open,
|
|
2743
|
+
unread: false
|
|
2744
|
+
}
|
|
2745
|
+
} : state;
|
|
2746
|
+
case "showPast": return {
|
|
2747
|
+
...state,
|
|
2748
|
+
view: "past",
|
|
2749
|
+
readOnly: false,
|
|
2750
|
+
failure: null
|
|
2751
|
+
};
|
|
2752
|
+
case "pastLoaded": return {
|
|
2753
|
+
...state,
|
|
2754
|
+
past: action.past,
|
|
2755
|
+
hasPast: state.hasPast || action.past.length > 0
|
|
2756
|
+
};
|
|
2757
|
+
case "openPast": return {
|
|
2758
|
+
...state,
|
|
2759
|
+
view: "conversation",
|
|
2760
|
+
readOnly: true,
|
|
2761
|
+
conversation: action.conversation,
|
|
2762
|
+
failure: null
|
|
2763
|
+
};
|
|
2764
|
+
case "back": return {
|
|
2765
|
+
...state,
|
|
2766
|
+
view: state.view === "conversation" && state.readOnly ? "past" : "assistant",
|
|
2767
|
+
readOnly: false,
|
|
2768
|
+
failure: null
|
|
2769
|
+
};
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
/** What the bar and the start screen row say, brought up to the messages on screen. */
|
|
2773
|
+
function summarized({ open, hasPast }, conversation) {
|
|
2774
|
+
if (conversation.closed) return {
|
|
2775
|
+
open: null,
|
|
2776
|
+
hasPast: true
|
|
2777
|
+
};
|
|
2778
|
+
const last = conversation.messages.at(-1);
|
|
2779
|
+
if (!open || !last) return {
|
|
2780
|
+
open,
|
|
2781
|
+
hasPast
|
|
2782
|
+
};
|
|
2783
|
+
return {
|
|
2784
|
+
open: {
|
|
2785
|
+
...open,
|
|
2786
|
+
status: conversation.status,
|
|
2787
|
+
lastMessageAt: last.sentAt,
|
|
2788
|
+
lastMessageBy: last.by,
|
|
2789
|
+
lastMessageText: last.text
|
|
2790
|
+
},
|
|
2791
|
+
hasPast
|
|
2792
|
+
};
|
|
2793
|
+
}
|
|
2794
|
+
/** Talk to a person is offered: the project's switch is on and the runtime has not said off. */
|
|
2795
|
+
function offersTalk(switchOn, hasTransport, support) {
|
|
2796
|
+
return switchOn && hasTransport && !support.off;
|
|
2797
|
+
}
|
|
2798
|
+
/** A full view replaces what is held; a view fetched with `after` adds its messages. */
|
|
2799
|
+
function merged(current, next) {
|
|
2800
|
+
if (current === null || current.id !== next.id) return next;
|
|
2801
|
+
return {
|
|
2802
|
+
...next,
|
|
2803
|
+
messages: withMessages(current.messages, next.messages)
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
function withMessages(current, next) {
|
|
2807
|
+
const seen = new Set(current.map((message) => message.id));
|
|
2808
|
+
return [...current, ...next.filter((message) => !seen.has(message.id))];
|
|
2809
|
+
}
|
|
2810
|
+
/** The conversation on screen can take a message: not one opened from the past list. */
|
|
2811
|
+
function canWrite(support) {
|
|
2812
|
+
return support.view === "conversation" && !support.readOnly;
|
|
2813
|
+
}
|
|
2814
|
+
//#endregion
|
|
2378
2815
|
//#region src/hooks.ts
|
|
2379
2816
|
function useGuidesApi() {
|
|
2380
2817
|
const api = useContext(GuidesContext);
|
|
@@ -2494,7 +2931,7 @@ function useLauncher({ remember = true } = {}) {
|
|
|
2494
2931
|
const asked = {
|
|
2495
2932
|
question,
|
|
2496
2933
|
pathname,
|
|
2497
|
-
|
|
2934
|
+
threadId: threadRef.current.threadId
|
|
2498
2935
|
};
|
|
2499
2936
|
run(transportAsk, asked, questionId, controller.signal, dispatch);
|
|
2500
2937
|
};
|
|
@@ -2504,6 +2941,7 @@ function useLauncher({ remember = true } = {}) {
|
|
|
2504
2941
|
ready: api.ready,
|
|
2505
2942
|
tourRunning: api.activeTour !== null,
|
|
2506
2943
|
messages: thread.messages,
|
|
2944
|
+
threadId: thread.threadId,
|
|
2507
2945
|
waiting: isWaiting(thread),
|
|
2508
2946
|
rateLimitedUntil: cappedUntil,
|
|
2509
2947
|
handedOff: thread.handoff !== null,
|
|
@@ -2576,6 +3014,157 @@ function useLauncher({ remember = true } = {}) {
|
|
|
2576
3014
|
}
|
|
2577
3015
|
};
|
|
2578
3016
|
}
|
|
3017
|
+
/** How often the end user's open conversation is checked, and the conversation on screen. */
|
|
3018
|
+
const SUPPORT_POLL_MS = 3e4;
|
|
3019
|
+
const CONVERSATION_POLL_MS = 5e3;
|
|
3020
|
+
function isOff(value) {
|
|
3021
|
+
return typeof value === "object" && value !== null && "off" in value;
|
|
3022
|
+
}
|
|
3023
|
+
function useTabVisible() {
|
|
3024
|
+
const [visible, setVisible] = useState(() => typeof document === "undefined" || document.visibilityState === "visible");
|
|
3025
|
+
useEffect(() => {
|
|
3026
|
+
const onChange = () => setVisible(document.visibilityState === "visible");
|
|
3027
|
+
document.addEventListener("visibilitychange", onChange);
|
|
3028
|
+
return () => document.removeEventListener("visibilitychange", onChange);
|
|
3029
|
+
}, []);
|
|
3030
|
+
return visible;
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Talk to a person. Polls the runtime only while it can matter: once on load,
|
|
3034
|
+
* then every 30 seconds while the end user has an open conversation, and the
|
|
3035
|
+
* conversation itself every 5 seconds while it is on screen. Nothing polls
|
|
3036
|
+
* while the tab is hidden.
|
|
3037
|
+
*/
|
|
3038
|
+
function useSupport({ onScreen, threadId }) {
|
|
3039
|
+
const api = useGuidesApi();
|
|
3040
|
+
const switchOn = useComponentSettings().launcher.talkToPerson;
|
|
3041
|
+
const transport = api.support;
|
|
3042
|
+
const [state, dispatch] = useReducer(supportReducer, emptySupport);
|
|
3043
|
+
const stateRef = useRef(state);
|
|
3044
|
+
stateRef.current = state;
|
|
3045
|
+
const visible = useTabVisible();
|
|
3046
|
+
const available = offersTalk(switchOn, transport !== void 0, state);
|
|
3047
|
+
const live = available ? transport : void 0;
|
|
3048
|
+
const hasOpen = state.open !== null;
|
|
3049
|
+
const poll = useCallback(() => {
|
|
3050
|
+
if (!live) return;
|
|
3051
|
+
live.conversationPoll().then((answer) => dispatch(isOff(answer) ? { type: "off" } : {
|
|
3052
|
+
type: "polled",
|
|
3053
|
+
poll: answer
|
|
3054
|
+
}), () => void 0);
|
|
3055
|
+
}, [live]);
|
|
3056
|
+
useEffect(() => {
|
|
3057
|
+
poll();
|
|
3058
|
+
}, [poll]);
|
|
3059
|
+
const wasVisible = useRef(visible);
|
|
3060
|
+
useEffect(() => {
|
|
3061
|
+
const returned = visible && !wasVisible.current;
|
|
3062
|
+
wasVisible.current = visible;
|
|
3063
|
+
if (!hasOpen || !visible) return;
|
|
3064
|
+
if (returned) poll();
|
|
3065
|
+
const timer = setInterval(poll, SUPPORT_POLL_MS);
|
|
3066
|
+
return () => clearInterval(timer);
|
|
3067
|
+
}, [
|
|
3068
|
+
poll,
|
|
3069
|
+
hasOpen,
|
|
3070
|
+
visible
|
|
3071
|
+
]);
|
|
3072
|
+
const watchedId = onScreen && visible && canWrite(state) ? state.conversation?.id ?? state.open?.id : void 0;
|
|
3073
|
+
useEffect(() => {
|
|
3074
|
+
if (!live || watchedId === void 0) return;
|
|
3075
|
+
const fetchView = () => {
|
|
3076
|
+
const held = stateRef.current.conversation;
|
|
3077
|
+
const after = held?.id === watchedId ? held.messages.at(-1)?.id : void 0;
|
|
3078
|
+
live.conversation(watchedId, after).then((view) => {
|
|
3079
|
+
if (view === null) return;
|
|
3080
|
+
if (isOff(view)) {
|
|
3081
|
+
dispatch({ type: "off" });
|
|
3082
|
+
return;
|
|
3083
|
+
}
|
|
3084
|
+
dispatch({
|
|
3085
|
+
type: "loaded",
|
|
3086
|
+
conversation: view
|
|
3087
|
+
});
|
|
3088
|
+
if (after !== void 0 && view.messages.some((message) => message.by === "TEAM")) live.markRead(watchedId).catch(() => void 0);
|
|
3089
|
+
}, () => void 0);
|
|
3090
|
+
};
|
|
3091
|
+
fetchView();
|
|
3092
|
+
const timer = setInterval(fetchView, CONVERSATION_POLL_MS);
|
|
3093
|
+
return () => clearInterval(timer);
|
|
3094
|
+
}, [live, watchedId]);
|
|
3095
|
+
const unreadOnScreen = state.open?.unread === true && state.open.id === watchedId;
|
|
3096
|
+
useEffect(() => {
|
|
3097
|
+
if (!live || !unreadOnScreen || watchedId === void 0) return;
|
|
3098
|
+
dispatch({
|
|
3099
|
+
type: "read",
|
|
3100
|
+
id: watchedId
|
|
3101
|
+
});
|
|
3102
|
+
live.markRead(watchedId).catch(() => void 0);
|
|
3103
|
+
}, [
|
|
3104
|
+
live,
|
|
3105
|
+
unreadOnScreen,
|
|
3106
|
+
watchedId
|
|
3107
|
+
]);
|
|
3108
|
+
return {
|
|
3109
|
+
available,
|
|
3110
|
+
state,
|
|
3111
|
+
talk: () => dispatch({ type: "talk" }),
|
|
3112
|
+
send: async (typed) => {
|
|
3113
|
+
const text = typed.trim();
|
|
3114
|
+
const current = stateRef.current;
|
|
3115
|
+
if (!live || text === "" || current.sending || !canWrite(current)) return false;
|
|
3116
|
+
dispatch({ type: "sending" });
|
|
3117
|
+
stateRef.current = {
|
|
3118
|
+
...current,
|
|
3119
|
+
sending: true
|
|
3120
|
+
};
|
|
3121
|
+
let answer;
|
|
3122
|
+
try {
|
|
3123
|
+
answer = current.conversation ? await live.sendMessage(current.conversation.id, text) : await live.startConversation({
|
|
3124
|
+
threadId: threadId ?? null,
|
|
3125
|
+
path: window.location.pathname,
|
|
3126
|
+
text
|
|
3127
|
+
});
|
|
3128
|
+
} catch {
|
|
3129
|
+
answer = { refused: "lost" };
|
|
3130
|
+
}
|
|
3131
|
+
if (isOff(answer)) {
|
|
3132
|
+
dispatch({ type: "off" });
|
|
3133
|
+
return false;
|
|
3134
|
+
}
|
|
3135
|
+
if ("refused" in answer) {
|
|
3136
|
+
const refused = answer.refused === "another-open" ? { refused: "lost" } : answer;
|
|
3137
|
+
dispatch({
|
|
3138
|
+
type: "refused",
|
|
3139
|
+
refused
|
|
3140
|
+
});
|
|
3141
|
+
return false;
|
|
3142
|
+
}
|
|
3143
|
+
dispatch({
|
|
3144
|
+
type: "sent",
|
|
3145
|
+
sent: answer
|
|
3146
|
+
});
|
|
3147
|
+
return true;
|
|
3148
|
+
},
|
|
3149
|
+
showPast: () => {
|
|
3150
|
+
dispatch({ type: "showPast" });
|
|
3151
|
+
live?.pastConversations().then((past) => dispatch(isOff(past) ? { type: "off" } : {
|
|
3152
|
+
type: "pastLoaded",
|
|
3153
|
+
past
|
|
3154
|
+
}), () => void 0);
|
|
3155
|
+
},
|
|
3156
|
+
openPast: (id) => {
|
|
3157
|
+
live?.conversation(id).then((view) => {
|
|
3158
|
+
if (view === null) return;
|
|
3159
|
+
dispatch(isOff(view) ? { type: "off" } : {
|
|
3160
|
+
type: "openPast",
|
|
3161
|
+
conversation: view
|
|
3162
|
+
});
|
|
3163
|
+
}, () => void 0);
|
|
3164
|
+
},
|
|
3165
|
+
back: () => dispatch({ type: "back" })
|
|
3166
|
+
};
|
|
3167
|
+
}
|
|
2579
3168
|
/** One guide's content plus launchTour; call with no key for launch-only. */
|
|
2580
3169
|
function useGuide(key) {
|
|
2581
3170
|
const api = useGuidesApi();
|
|
@@ -2764,6 +3353,28 @@ function StarIcon() {
|
|
|
2764
3353
|
children: /* @__PURE__ */ jsx("path", { d: "M11.53 2.3a.5.5 0 0 1 .94 0l2.31 4.68a2 2 0 0 0 1.5 1.1l5.17.75a.5.5 0 0 1 .28.86l-3.74 3.64a2 2 0 0 0-.58 1.77l.88 5.14a.5.5 0 0 1-.73.53l-4.62-2.43a2 2 0 0 0-1.86 0l-4.62 2.43a.5.5 0 0 1-.73-.53l.88-5.14a2 2 0 0 0-.57-1.77L2.3 9.69a.5.5 0 0 1 .28-.86l5.16-.75a2 2 0 0 0 1.52-1.1z" })
|
|
2765
3354
|
});
|
|
2766
3355
|
}
|
|
3356
|
+
function ChevronLeftIcon() {
|
|
3357
|
+
return /* @__PURE__ */ jsx(Glyph, {
|
|
3358
|
+
width: 1.8,
|
|
3359
|
+
children: /* @__PURE__ */ jsx("path", { d: "m15 18-6-6 6-6" })
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
3362
|
+
function HeadsetIcon() {
|
|
3363
|
+
return /* @__PURE__ */ jsxs(Glyph, {
|
|
3364
|
+
width: 1.8,
|
|
3365
|
+
children: [/* @__PURE__ */ jsx("path", { d: "M3 11h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5Zm0 0a9 9 0 1 1 18 0m0 0v5a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3Z" }), /* @__PURE__ */ jsx("path", { d: "M21 16v2a4 4 0 0 1-4 4h-5" })]
|
|
3366
|
+
});
|
|
3367
|
+
}
|
|
3368
|
+
function HistoryIcon() {
|
|
3369
|
+
return /* @__PURE__ */ jsxs(Glyph, {
|
|
3370
|
+
width: 1.8,
|
|
3371
|
+
children: [
|
|
3372
|
+
/* @__PURE__ */ jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
|
|
3373
|
+
/* @__PURE__ */ jsx("path", { d: "M3 3v5h5" }),
|
|
3374
|
+
/* @__PURE__ */ jsx("path", { d: "M12 7v5l4 2" })
|
|
3375
|
+
]
|
|
3376
|
+
});
|
|
3377
|
+
}
|
|
2767
3378
|
//#endregion
|
|
2768
3379
|
//#region src/overlay/CardMedia.tsx
|
|
2769
3380
|
const VIDEO_URL = /\.(mp4|webm|m4v|mov|ogg)([?#]|$)/i;
|
|
@@ -3851,10 +4462,387 @@ function SunboardLogo({ width = 75 }) {
|
|
|
3851
4462
|
}) })]
|
|
3852
4463
|
});
|
|
3853
4464
|
}
|
|
4465
|
+
const FOCUSABLE = "a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled])";
|
|
4466
|
+
/**
|
|
4467
|
+
* On a phone the panel is a bottom sheet, and focusing the box raises the
|
|
4468
|
+
* soft keyboard over what the visitor came to read. The panel is fixed, so
|
|
4469
|
+
* focus never needs to scroll, and in an iframe it would scroll the parent.
|
|
4470
|
+
*/
|
|
4471
|
+
function useOpeningFocus(inline, focusBox, boxRef, panelRef) {
|
|
4472
|
+
useEffect(() => {
|
|
4473
|
+
if (inline) return;
|
|
4474
|
+
const phone = window.matchMedia?.(`(max-width: 640px)`).matches ?? false;
|
|
4475
|
+
const box = boxRef.current;
|
|
4476
|
+
if ((focusBox || !phone) && box && !box.disabled) box.focus({ preventScroll: true });
|
|
4477
|
+
else panelRef.current?.focus({ preventScroll: true });
|
|
4478
|
+
}, [inline]);
|
|
4479
|
+
}
|
|
4480
|
+
/**
|
|
4481
|
+
* Bound to the document rather than the panel: disabling the box the visitor
|
|
4482
|
+
* was typing in hands focus back to the page, and a listener on the panel
|
|
4483
|
+
* would never hear the Tab that follows.
|
|
4484
|
+
*/
|
|
4485
|
+
function useTabTrap(inline, panelRef) {
|
|
4486
|
+
useEffect(() => {
|
|
4487
|
+
if (inline) return;
|
|
4488
|
+
const onKeyDown = (event) => {
|
|
4489
|
+
const panel = panelRef.current;
|
|
4490
|
+
if (event.key !== "Tab" || event.defaultPrevented || !panel) return;
|
|
4491
|
+
const stops = [...panel.querySelectorAll(FOCUSABLE)];
|
|
4492
|
+
if (stops.length === 0) return;
|
|
4493
|
+
const first = stops[0];
|
|
4494
|
+
const last = stops[stops.length - 1];
|
|
4495
|
+
const inside = panel.contains(document.activeElement);
|
|
4496
|
+
const leaving = document.activeElement === (event.shiftKey ? first : last);
|
|
4497
|
+
if (inside && !leaving) return;
|
|
4498
|
+
event.preventDefault();
|
|
4499
|
+
(event.shiftKey ? last : first).focus();
|
|
4500
|
+
};
|
|
4501
|
+
document.addEventListener("keydown", onKeyDown);
|
|
4502
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
4503
|
+
}, [inline, panelRef]);
|
|
4504
|
+
}
|
|
4505
|
+
function clockTime(iso) {
|
|
4506
|
+
const at = new Date(iso);
|
|
4507
|
+
if (Number.isNaN(at.getTime())) return iso;
|
|
4508
|
+
return at.toLocaleTimeString(void 0, {
|
|
4509
|
+
hour: "2-digit",
|
|
4510
|
+
minute: "2-digit"
|
|
4511
|
+
});
|
|
4512
|
+
}
|
|
4513
|
+
/** Whole calendar days from `iso` to `now`, in the visitor's own time zone. */
|
|
4514
|
+
function daysAgo(iso, now) {
|
|
4515
|
+
const at = new Date(iso);
|
|
4516
|
+
if (Number.isNaN(at.getTime())) return 0;
|
|
4517
|
+
const midnight = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
4518
|
+
return Math.max(0, Math.round((midnight(now) - midnight(at)) / 864e5));
|
|
4519
|
+
}
|
|
4520
|
+
function minutesUntil(iso) {
|
|
4521
|
+
const at = new Date(iso).getTime();
|
|
4522
|
+
if (Number.isNaN(at)) return 1;
|
|
4523
|
+
return Math.max(1, Math.round((at - Date.now()) / 6e4));
|
|
4524
|
+
}
|
|
4525
|
+
//#endregion
|
|
4526
|
+
//#region src/components/Conversation.tsx
|
|
4527
|
+
/** "Sean replied" or "Waiting for a reply", for the bar and the start screen row. */
|
|
4528
|
+
function supportStatus(open, company, labels) {
|
|
4529
|
+
if (open.lastMessageBy !== "TEAM") return labels.waiting;
|
|
4530
|
+
return labels.replied(open.lastMessageWho ?? labels.from(company));
|
|
4531
|
+
}
|
|
4532
|
+
function when(iso, labels, now) {
|
|
4533
|
+
const days = daysAgo(iso, now);
|
|
4534
|
+
return days === 0 ? clockTime(iso) : labels.ago(days);
|
|
4535
|
+
}
|
|
4536
|
+
function PanelHead({ title, onBack, close }) {
|
|
4537
|
+
const labels = useLabels();
|
|
4538
|
+
return /* @__PURE__ */ jsxs("header", {
|
|
4539
|
+
className: "sb-head sb-head-back",
|
|
4540
|
+
children: [
|
|
4541
|
+
/* @__PURE__ */ jsx("button", {
|
|
4542
|
+
type: "button",
|
|
4543
|
+
className: "sb-panel-close",
|
|
4544
|
+
"aria-label": labels.support.back,
|
|
4545
|
+
onClick: onBack,
|
|
4546
|
+
children: /* @__PURE__ */ jsx(ChevronLeftIcon, {})
|
|
4547
|
+
}),
|
|
4548
|
+
/* @__PURE__ */ jsx("h2", {
|
|
4549
|
+
className: "sb-title",
|
|
4550
|
+
children: title
|
|
4551
|
+
}),
|
|
4552
|
+
/* @__PURE__ */ jsx("button", {
|
|
4553
|
+
type: "button",
|
|
4554
|
+
className: "sb-panel-close",
|
|
4555
|
+
"aria-label": labels.launcher.closeAria,
|
|
4556
|
+
onClick: close,
|
|
4557
|
+
children: /* @__PURE__ */ jsx(XIcon, {})
|
|
4558
|
+
})
|
|
4559
|
+
]
|
|
4560
|
+
});
|
|
4561
|
+
}
|
|
4562
|
+
function Branding() {
|
|
4563
|
+
const L = useLabels().launcher;
|
|
4564
|
+
if (!useComponentSettings().launcher.showBranding) return null;
|
|
4565
|
+
return /* @__PURE__ */ jsxs("a", {
|
|
4566
|
+
className: "sb-brand",
|
|
4567
|
+
href: "https://sunboardhq.com",
|
|
4568
|
+
target: "_blank",
|
|
4569
|
+
rel: "noreferrer",
|
|
4570
|
+
children: [L.poweredBy, /* @__PURE__ */ jsx(SunboardLogo, { width: 62 })]
|
|
4571
|
+
});
|
|
4572
|
+
}
|
|
4573
|
+
/** The messages and the questions asked of the assistant meanwhile, in the order they happened. */
|
|
4574
|
+
function items(conversation) {
|
|
4575
|
+
return [...conversation.messages.map((message) => ({
|
|
4576
|
+
kind: "message",
|
|
4577
|
+
at: message.sentAt,
|
|
4578
|
+
message
|
|
4579
|
+
})), ...conversation.during.map((question) => ({
|
|
4580
|
+
kind: "fold",
|
|
4581
|
+
at: question.askedAt,
|
|
4582
|
+
question
|
|
4583
|
+
}))].sort((a, b) => a.at.localeCompare(b.at));
|
|
4584
|
+
}
|
|
4585
|
+
/** The assistant's turns the conversation follows on from, dimmed. */
|
|
4586
|
+
function AssistantTurns({ messages }) {
|
|
4587
|
+
const replies = new Map(messages.flatMap((message) => message.kind === "reply" ? [[message.questionId, message.reply.answer]] : []));
|
|
4588
|
+
return /* @__PURE__ */ jsx("div", {
|
|
4589
|
+
className: "sb-dim",
|
|
4590
|
+
children: messages.map((message) => {
|
|
4591
|
+
if (message.kind !== "question") return null;
|
|
4592
|
+
const answer = replies.get(message.id);
|
|
4593
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
4594
|
+
className: "sb-exchange",
|
|
4595
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
4596
|
+
className: "sb-asked",
|
|
4597
|
+
children: /* @__PURE__ */ jsx("p", { children: message.text })
|
|
4598
|
+
}), answer && /* @__PURE__ */ jsx("div", {
|
|
4599
|
+
className: "sb-reply-text",
|
|
4600
|
+
children: /* @__PURE__ */ jsx(AnswerText, { text: answer })
|
|
4601
|
+
})]
|
|
4602
|
+
}, message.id);
|
|
4603
|
+
})
|
|
4604
|
+
});
|
|
4605
|
+
}
|
|
4606
|
+
function Fold({ question, labels }) {
|
|
4607
|
+
const [open, setOpen] = useState(false);
|
|
4608
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
4609
|
+
type: "button",
|
|
4610
|
+
className: `sb-fold${open ? " sb-fold-open" : ""}`,
|
|
4611
|
+
"aria-expanded": open,
|
|
4612
|
+
onClick: () => setOpen((now) => !now),
|
|
4613
|
+
children: [/* @__PURE__ */ jsx(ChevronRightIcon, {}), /* @__PURE__ */ jsx("span", {
|
|
4614
|
+
className: "sb-fold-text",
|
|
4615
|
+
children: labels.asked(question.text)
|
|
4616
|
+
})]
|
|
4617
|
+
});
|
|
4618
|
+
}
|
|
4619
|
+
function Line({ children }) {
|
|
4620
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
4621
|
+
className: "sb-line",
|
|
4622
|
+
role: "note",
|
|
4623
|
+
children: [
|
|
4624
|
+
/* @__PURE__ */ jsx("span", { className: "sb-line-rule" }),
|
|
4625
|
+
/* @__PURE__ */ jsx("span", {
|
|
4626
|
+
className: "sb-line-text",
|
|
4627
|
+
children
|
|
4628
|
+
}),
|
|
4629
|
+
/* @__PURE__ */ jsx("span", { className: "sb-line-rule" })
|
|
4630
|
+
]
|
|
4631
|
+
});
|
|
4632
|
+
}
|
|
4633
|
+
function TeamMessage({ who, text }) {
|
|
4634
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
4635
|
+
className: "sb-team",
|
|
4636
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
4637
|
+
className: "sb-team-who",
|
|
4638
|
+
children: who
|
|
4639
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
4640
|
+
className: "sb-team-text",
|
|
4641
|
+
children: text
|
|
4642
|
+
})]
|
|
4643
|
+
});
|
|
4644
|
+
}
|
|
4645
|
+
function SendFailure({ failure, labels }) {
|
|
4646
|
+
const capped = failure.refused === "too-many";
|
|
4647
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
4648
|
+
className: "sb-note sb-arrive",
|
|
4649
|
+
role: capped ? "status" : "alert",
|
|
4650
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
4651
|
+
className: "sb-note-mark",
|
|
4652
|
+
children: capped ? /* @__PURE__ */ jsx(ClockIcon, {}) : /* @__PURE__ */ jsx(NoSignalIcon, {})
|
|
4653
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
4654
|
+
className: "sb-note-text",
|
|
4655
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
4656
|
+
className: "sb-note-words",
|
|
4657
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
4658
|
+
className: "sb-note-title",
|
|
4659
|
+
children: capped ? labels.tooMany : labels.lost
|
|
4660
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
4661
|
+
className: "sb-note-body",
|
|
4662
|
+
children: capped && failure.retryAt ? labels.writeAgainIn(minutesUntil(failure.retryAt)) : labels.lostBody
|
|
4663
|
+
})]
|
|
4664
|
+
})
|
|
4665
|
+
})]
|
|
4666
|
+
});
|
|
4667
|
+
}
|
|
4668
|
+
function ConversationPanel({ launcher, support, close, focusBox }) {
|
|
4669
|
+
const api = useGuidesApi();
|
|
4670
|
+
const { support: S, launcher: L } = useLabels();
|
|
4671
|
+
const replyTime = useComponentSettings().launcher.replyTime.trim();
|
|
4672
|
+
const company = api.productName;
|
|
4673
|
+
const { conversation, readOnly, sending, failure } = support.state;
|
|
4674
|
+
const panelRef = useRef(null);
|
|
4675
|
+
const boxRef = useRef(null);
|
|
4676
|
+
const [draft, setDraft] = useState("");
|
|
4677
|
+
const now = /* @__PURE__ */ new Date();
|
|
4678
|
+
useOpeningFocus(false, focusBox, boxRef, panelRef);
|
|
4679
|
+
useTabTrap(false, panelRef);
|
|
4680
|
+
useLayoutEffect(() => {
|
|
4681
|
+
const box = boxRef.current;
|
|
4682
|
+
if (!box) return;
|
|
4683
|
+
box.style.height = "";
|
|
4684
|
+
box.style.height = `${String(box.scrollHeight)}px`;
|
|
4685
|
+
box.style.overflowY = box.scrollHeight > box.clientHeight + 1 ? "auto" : "hidden";
|
|
4686
|
+
}, [draft]);
|
|
4687
|
+
const send = async (event) => {
|
|
4688
|
+
event?.preventDefault();
|
|
4689
|
+
if (await support.send(draft)) setDraft("");
|
|
4690
|
+
};
|
|
4691
|
+
const onBoxKeyDown = (event) => {
|
|
4692
|
+
if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
|
|
4693
|
+
event.preventDefault();
|
|
4694
|
+
send();
|
|
4695
|
+
};
|
|
4696
|
+
const questions = launcher.messages.filter((message) => message.kind === "question" || message.kind === "reply");
|
|
4697
|
+
const showTurns = !readOnly && questions.length > 0 && (conversation === null || conversation.before.length > 0);
|
|
4698
|
+
const firstFromEndUser = conversation?.messages.find((message) => message.by === "END_USER")?.id;
|
|
4699
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
4700
|
+
className: "sb-panel",
|
|
4701
|
+
ref: panelRef,
|
|
4702
|
+
role: "dialog",
|
|
4703
|
+
"aria-label": S.title(company),
|
|
4704
|
+
tabIndex: -1,
|
|
4705
|
+
children: [
|
|
4706
|
+
/* @__PURE__ */ jsx(PanelHead, {
|
|
4707
|
+
title: S.title(company),
|
|
4708
|
+
onBack: support.back,
|
|
4709
|
+
close
|
|
4710
|
+
}),
|
|
4711
|
+
/* @__PURE__ */ jsx("div", {
|
|
4712
|
+
className: "sb-thread",
|
|
4713
|
+
role: "log",
|
|
4714
|
+
"aria-label": S.conversationWith(company),
|
|
4715
|
+
"aria-live": "polite",
|
|
4716
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
4717
|
+
className: "sb-thread-scroll sb-anchored",
|
|
4718
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
4719
|
+
className: "sb-thread-list sb-conversation",
|
|
4720
|
+
children: [
|
|
4721
|
+
showTurns && /* @__PURE__ */ jsx(AssistantTurns, { messages: questions }),
|
|
4722
|
+
!showTurns && conversation?.before.map((question) => /* @__PURE__ */ jsx(Fold, {
|
|
4723
|
+
question,
|
|
4724
|
+
labels: S
|
|
4725
|
+
}, question.id)),
|
|
4726
|
+
/* @__PURE__ */ jsx(Line, { children: S.talking(company) }),
|
|
4727
|
+
/* @__PURE__ */ jsx(TeamMessage, {
|
|
4728
|
+
who: S.from(company),
|
|
4729
|
+
text: S.greeting(api.firstName)
|
|
4730
|
+
}),
|
|
4731
|
+
conversation && items(conversation).map((item) => item.kind === "fold" ? /* @__PURE__ */ jsx(Fold, {
|
|
4732
|
+
question: item.question,
|
|
4733
|
+
labels: S
|
|
4734
|
+
}, item.question.id) : item.message.by === "TEAM" ? /* @__PURE__ */ jsx(TeamMessage, {
|
|
4735
|
+
who: S.teamMessage(item.message.who, company, when(item.message.sentAt, S, now)),
|
|
4736
|
+
text: item.message.text
|
|
4737
|
+
}, item.message.id) : /* @__PURE__ */ jsxs("div", {
|
|
4738
|
+
className: "sb-asked sb-sent",
|
|
4739
|
+
children: [/* @__PURE__ */ jsx("p", { children: item.message.text }), item.message.id === firstFromEndUser && replyTime !== "" && /* @__PURE__ */ jsx("span", {
|
|
4740
|
+
className: "sb-reply-time",
|
|
4741
|
+
children: replyTime
|
|
4742
|
+
})]
|
|
4743
|
+
}, item.message.id)),
|
|
4744
|
+
conversation?.closed && /* @__PURE__ */ jsxs("div", {
|
|
4745
|
+
className: "sb-closed",
|
|
4746
|
+
children: [/* @__PURE__ */ jsx(Line, { children: S.closed(company) }), !readOnly && /* @__PURE__ */ jsx("p", {
|
|
4747
|
+
className: "sb-reopen",
|
|
4748
|
+
children: S.reopen
|
|
4749
|
+
})]
|
|
4750
|
+
}),
|
|
4751
|
+
failure && /* @__PURE__ */ jsx(SendFailure, {
|
|
4752
|
+
failure,
|
|
4753
|
+
labels: S
|
|
4754
|
+
})
|
|
4755
|
+
]
|
|
4756
|
+
})
|
|
4757
|
+
})
|
|
4758
|
+
}),
|
|
4759
|
+
readOnly ? /* @__PURE__ */ jsx("div", {
|
|
4760
|
+
className: "sb-foot",
|
|
4761
|
+
children: /* @__PURE__ */ jsx(Branding, {})
|
|
4762
|
+
}) : /* @__PURE__ */ jsxs("form", {
|
|
4763
|
+
className: "sb-composer",
|
|
4764
|
+
onSubmit: (event) => void send(event),
|
|
4765
|
+
children: [/* @__PURE__ */ jsxs("label", {
|
|
4766
|
+
className: "sb-field",
|
|
4767
|
+
children: [
|
|
4768
|
+
/* @__PURE__ */ jsx("span", {
|
|
4769
|
+
className: "sb-sr",
|
|
4770
|
+
children: S.boxLabel(company)
|
|
4771
|
+
}),
|
|
4772
|
+
/* @__PURE__ */ jsx("textarea", {
|
|
4773
|
+
className: "sb-box",
|
|
4774
|
+
ref: boxRef,
|
|
4775
|
+
rows: 1,
|
|
4776
|
+
value: draft,
|
|
4777
|
+
onChange: (event) => setDraft(event.target.value),
|
|
4778
|
+
onKeyDown: onBoxKeyDown,
|
|
4779
|
+
placeholder: S.placeholder(company),
|
|
4780
|
+
maxLength: CONVERSATION_MESSAGE_MAX_LENGTH,
|
|
4781
|
+
autoComplete: "off"
|
|
4782
|
+
}),
|
|
4783
|
+
/* @__PURE__ */ jsx("button", {
|
|
4784
|
+
type: "submit",
|
|
4785
|
+
className: "sb-send",
|
|
4786
|
+
"aria-label": L.send,
|
|
4787
|
+
disabled: sending || draft.trim() === "",
|
|
4788
|
+
children: /* @__PURE__ */ jsx(SendIcon, {})
|
|
4789
|
+
})
|
|
4790
|
+
]
|
|
4791
|
+
}), /* @__PURE__ */ jsx(Branding, {})]
|
|
4792
|
+
})
|
|
4793
|
+
]
|
|
4794
|
+
});
|
|
4795
|
+
}
|
|
4796
|
+
function PastPanel({ support, close }) {
|
|
4797
|
+
const S = useLabels().support;
|
|
4798
|
+
const panelRef = useRef(null);
|
|
4799
|
+
const now = /* @__PURE__ */ new Date();
|
|
4800
|
+
useTabTrap(false, panelRef);
|
|
4801
|
+
useLayoutEffect(() => {
|
|
4802
|
+
panelRef.current?.focus({ preventScroll: true });
|
|
4803
|
+
}, []);
|
|
4804
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
4805
|
+
className: "sb-panel",
|
|
4806
|
+
ref: panelRef,
|
|
4807
|
+
role: "dialog",
|
|
4808
|
+
"aria-label": S.past,
|
|
4809
|
+
tabIndex: -1,
|
|
4810
|
+
children: [
|
|
4811
|
+
/* @__PURE__ */ jsx(PanelHead, {
|
|
4812
|
+
title: S.past,
|
|
4813
|
+
onBack: support.back,
|
|
4814
|
+
close
|
|
4815
|
+
}),
|
|
4816
|
+
/* @__PURE__ */ jsx("div", {
|
|
4817
|
+
className: "sb-thread",
|
|
4818
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
4819
|
+
className: "sb-thread-scroll",
|
|
4820
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
4821
|
+
className: "sb-past-list",
|
|
4822
|
+
children: (support.state.past ?? []).map((past) => /* @__PURE__ */ jsxs("button", {
|
|
4823
|
+
type: "button",
|
|
4824
|
+
className: "sb-past",
|
|
4825
|
+
onClick: () => support.openPast(past.id),
|
|
4826
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
4827
|
+
className: "sb-past-title",
|
|
4828
|
+
children: past.title
|
|
4829
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
4830
|
+
className: "sb-past-meta",
|
|
4831
|
+
children: S.closedWhen(S.ago(daysAgo(past.closedAt ?? now.toISOString(), now)), past.closedBy)
|
|
4832
|
+
})]
|
|
4833
|
+
}, past.id))
|
|
4834
|
+
})
|
|
4835
|
+
})
|
|
4836
|
+
}),
|
|
4837
|
+
/* @__PURE__ */ jsx("div", {
|
|
4838
|
+
className: "sb-foot",
|
|
4839
|
+
children: /* @__PURE__ */ jsx(Branding, {})
|
|
4840
|
+
})
|
|
4841
|
+
]
|
|
4842
|
+
});
|
|
4843
|
+
}
|
|
3854
4844
|
//#endregion
|
|
3855
4845
|
//#region src/components/Launcher.tsx
|
|
3856
|
-
/** The width below which the panel is a bottom sheet; see styles.css. */
|
|
3857
|
-
const PHONE_WIDTH = 640;
|
|
3858
4846
|
/** How long the stage labels stay before the wait owns up to being slow. */
|
|
3859
4847
|
const SLOW_MS = 6e3;
|
|
3860
4848
|
const STAGE_AT = {
|
|
@@ -3862,14 +4850,19 @@ const STAGE_AT = {
|
|
|
3862
4850
|
writing: 1,
|
|
3863
4851
|
checking: 2
|
|
3864
4852
|
};
|
|
3865
|
-
const FOCUSABLE = "a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled])";
|
|
3866
4853
|
function Launcher({ inline } = {}) {
|
|
3867
4854
|
const launcher = useLauncher({ remember: !inline });
|
|
3868
4855
|
const { ready, onThisPage, elsewhere, tourRunning, opened, handedOff } = launcher;
|
|
3869
4856
|
const [open, setOpen] = useState(false);
|
|
4857
|
+
const support = useSupport({
|
|
4858
|
+
onScreen: open !== false,
|
|
4859
|
+
threadId: launcher.threadId
|
|
4860
|
+
});
|
|
3870
4861
|
const { off } = useGuidesApi();
|
|
3871
4862
|
const canAsk = launcher.ask !== void 0;
|
|
3872
4863
|
const L = useLabels().launcher;
|
|
4864
|
+
const S = useLabels().support;
|
|
4865
|
+
const unread = support.available && support.state.open?.unread === true;
|
|
3873
4866
|
const pillRef = useRef(null);
|
|
3874
4867
|
const wasOpen = useRef(false);
|
|
3875
4868
|
const wasHandedOff = useRef(handedOff);
|
|
@@ -3896,31 +4889,49 @@ function Launcher({ inline } = {}) {
|
|
|
3896
4889
|
if (inline) return /* @__PURE__ */ jsx(Scope, { children: /* @__PURE__ */ jsx(QuestionPanel, {
|
|
3897
4890
|
inline: true,
|
|
3898
4891
|
launcher,
|
|
4892
|
+
support,
|
|
3899
4893
|
close: () => setOpen(false),
|
|
3900
4894
|
focusBox: false
|
|
3901
4895
|
}) });
|
|
3902
4896
|
const floating = (surface) => /* @__PURE__ */ jsx(FloatingPortal, { children: /* @__PURE__ */ jsx(Scope, { children: surface }) });
|
|
3903
4897
|
if (!ready) return null;
|
|
3904
4898
|
if (tourRunning) return floating(/* @__PURE__ */ jsx(TourPill, {}));
|
|
3905
|
-
if (!canAsk && onThisPage.length + elsewhere.length === 0) return null;
|
|
3906
|
-
if (open)
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
4899
|
+
if (!canAsk && onThisPage.length + elsewhere.length === 0 && !support.state.open) return null;
|
|
4900
|
+
if (open) {
|
|
4901
|
+
const close = () => setOpen(false);
|
|
4902
|
+
const view = support.available ? support.state.view : "assistant";
|
|
4903
|
+
return floating(/* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
|
|
4904
|
+
className: "sb-panel-scrim",
|
|
4905
|
+
onClick: close
|
|
4906
|
+
}), view === "conversation" ? /* @__PURE__ */ jsx(ConversationPanel, {
|
|
4907
|
+
launcher,
|
|
4908
|
+
support,
|
|
4909
|
+
close,
|
|
4910
|
+
focusBox: open === "back"
|
|
4911
|
+
}) : view === "past" ? /* @__PURE__ */ jsx(PastPanel, {
|
|
4912
|
+
support,
|
|
4913
|
+
close
|
|
4914
|
+
}) : /* @__PURE__ */ jsx(QuestionPanel, {
|
|
4915
|
+
launcher,
|
|
4916
|
+
support,
|
|
4917
|
+
close,
|
|
4918
|
+
focusBox: open === "back"
|
|
4919
|
+
})] }));
|
|
4920
|
+
}
|
|
3914
4921
|
return floating(/* @__PURE__ */ jsxs("button", {
|
|
3915
4922
|
type: "button",
|
|
3916
4923
|
className: "sb-launcher",
|
|
3917
4924
|
ref: pillRef,
|
|
3918
|
-
"aria-label": L.openAria,
|
|
4925
|
+
"aria-label": unread ? S.openAriaNewReply : L.openAria,
|
|
3919
4926
|
onClick: () => {
|
|
3920
4927
|
setOpen("opened");
|
|
3921
4928
|
opened();
|
|
3922
4929
|
},
|
|
3923
|
-
children: [
|
|
4930
|
+
children: [
|
|
4931
|
+
/* @__PURE__ */ jsx(HelpIcon, {}),
|
|
4932
|
+
L.open,
|
|
4933
|
+
unread && /* @__PURE__ */ jsx("span", { className: "sb-dot sb-launcher-dot" })
|
|
4934
|
+
]
|
|
3924
4935
|
}));
|
|
3925
4936
|
}
|
|
3926
4937
|
/**
|
|
@@ -3928,10 +4939,14 @@ function Launcher({ inline } = {}) {
|
|
|
3928
4939
|
* tour taking its place, loses nothing; a question still out lands while the
|
|
3929
4940
|
* panel is away and is there when it comes back.
|
|
3930
4941
|
*/
|
|
3931
|
-
function QuestionPanel({ inline, launcher, close, focusBox }) {
|
|
4942
|
+
function QuestionPanel({ inline, launcher, support, close, focusBox }) {
|
|
3932
4943
|
const { onThisPage, elsewhere, messages, waiting, rateLimitedUntil, ask } = launcher;
|
|
3933
4944
|
const api = useGuidesApi();
|
|
3934
4945
|
const L = useLabels().launcher;
|
|
4946
|
+
const S = useLabels().support;
|
|
4947
|
+
const company = api.productName;
|
|
4948
|
+
const talk = support.available && !inline ? support.talk : void 0;
|
|
4949
|
+
const openConversation = support.available ? support.state.open : null;
|
|
3935
4950
|
const showBranding = useComponentSettings().launcher.showBranding;
|
|
3936
4951
|
const panelRef = useRef(null);
|
|
3937
4952
|
const scrollRef = useRef(null);
|
|
@@ -4004,31 +5019,8 @@ function QuestionPanel({ inline, launcher, close, focusBox }) {
|
|
|
4004
5019
|
}
|
|
4005
5020
|
if (finished !== null) setAnnouncement(finished);
|
|
4006
5021
|
}, [messages]);
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
const phone = window.matchMedia?.(`(max-width: ${PHONE_WIDTH}px)`).matches ?? false;
|
|
4010
|
-
const box = boxRef.current;
|
|
4011
|
-
if ((focusBox || !phone) && box && !box.disabled) box.focus({ preventScroll: true });
|
|
4012
|
-
else panelRef.current?.focus({ preventScroll: true });
|
|
4013
|
-
}, [inline]);
|
|
4014
|
-
useEffect(() => {
|
|
4015
|
-
if (inline) return;
|
|
4016
|
-
const onKeyDown = (event) => {
|
|
4017
|
-
const panel = panelRef.current;
|
|
4018
|
-
if (event.key !== "Tab" || event.defaultPrevented || !panel) return;
|
|
4019
|
-
const stops = [...panel.querySelectorAll(FOCUSABLE)];
|
|
4020
|
-
if (stops.length === 0) return;
|
|
4021
|
-
const first = stops[0];
|
|
4022
|
-
const last = stops[stops.length - 1];
|
|
4023
|
-
const inside = panel.contains(document.activeElement);
|
|
4024
|
-
const leaving = document.activeElement === (event.shiftKey ? first : last);
|
|
4025
|
-
if (inside && !leaving) return;
|
|
4026
|
-
event.preventDefault();
|
|
4027
|
-
(event.shiftKey ? last : first).focus();
|
|
4028
|
-
};
|
|
4029
|
-
document.addEventListener("keydown", onKeyDown);
|
|
4030
|
-
return () => document.removeEventListener("keydown", onKeyDown);
|
|
4031
|
-
}, [inline]);
|
|
5022
|
+
useOpeningFocus(inline, focusBox, boxRef, panelRef);
|
|
5023
|
+
useTabTrap(inline, panelRef);
|
|
4032
5024
|
useEffect(() => {
|
|
4033
5025
|
if (!confirming) return;
|
|
4034
5026
|
cancelRef.current?.focus();
|
|
@@ -4105,6 +5097,13 @@ function QuestionPanel({ inline, launcher, close, focusBox }) {
|
|
|
4105
5097
|
onClick: () => setConfirming((now) => !now),
|
|
4106
5098
|
children: [/* @__PURE__ */ jsx(RestartIcon, {}), L.startOver]
|
|
4107
5099
|
}),
|
|
5100
|
+
!inline && support.available && support.state.hasPast && /* @__PURE__ */ jsx("button", {
|
|
5101
|
+
type: "button",
|
|
5102
|
+
className: "sb-panel-close",
|
|
5103
|
+
"aria-label": S.past,
|
|
5104
|
+
onClick: support.showPast,
|
|
5105
|
+
children: /* @__PURE__ */ jsx(HistoryIcon, {})
|
|
5106
|
+
}),
|
|
4108
5107
|
!inline && /* @__PURE__ */ jsx("button", {
|
|
4109
5108
|
type: "button",
|
|
4110
5109
|
className: "sb-panel-close",
|
|
@@ -4147,10 +5146,30 @@ function QuestionPanel({ inline, launcher, close, focusBox }) {
|
|
|
4147
5146
|
})
|
|
4148
5147
|
]
|
|
4149
5148
|
}),
|
|
5149
|
+
openConversation && messages.length > 0 && /* @__PURE__ */ jsxs("button", {
|
|
5150
|
+
type: "button",
|
|
5151
|
+
className: "sb-convbar",
|
|
5152
|
+
onClick: talk,
|
|
5153
|
+
children: [
|
|
5154
|
+
openConversation.unread && /* @__PURE__ */ jsx("span", { className: "sb-dot" }),
|
|
5155
|
+
openConversation.unread && /* @__PURE__ */ jsx("span", {
|
|
5156
|
+
className: "sb-sr",
|
|
5157
|
+
children: S.newReply
|
|
5158
|
+
}),
|
|
5159
|
+
/* @__PURE__ */ jsxs("span", {
|
|
5160
|
+
className: "sb-convbar-text",
|
|
5161
|
+
children: [S.conversationWith(company), /* @__PURE__ */ jsxs("span", {
|
|
5162
|
+
className: "sb-convbar-status",
|
|
5163
|
+
children: [" · ", supportStatus(openConversation, company, S)]
|
|
5164
|
+
})]
|
|
5165
|
+
}),
|
|
5166
|
+
/* @__PURE__ */ jsx(ChevronRightIcon, {})
|
|
5167
|
+
]
|
|
5168
|
+
}),
|
|
4150
5169
|
/* @__PURE__ */ jsxs("div", {
|
|
4151
5170
|
className: "sb-thread",
|
|
4152
5171
|
role: "log",
|
|
4153
|
-
"aria-label": L.
|
|
5172
|
+
"aria-label": L.thread,
|
|
4154
5173
|
"aria-live": "off",
|
|
4155
5174
|
children: [/* @__PURE__ */ jsx("div", {
|
|
4156
5175
|
className: `sb-thread-scroll${anchored ? " sb-anchored" : ""}`,
|
|
@@ -4161,7 +5180,30 @@ function QuestionPanel({ inline, launcher, close, focusBox }) {
|
|
|
4161
5180
|
children: messages.length === 0 ? /* @__PURE__ */ jsx(Suggestions, {
|
|
4162
5181
|
labels: L,
|
|
4163
5182
|
guides: listed,
|
|
4164
|
-
onRow: inline ? void 0 : (guide) => start(guide.key)
|
|
5183
|
+
onRow: inline ? void 0 : (guide) => start(guide.key),
|
|
5184
|
+
conversation: openConversation && /* @__PURE__ */ jsxs("button", {
|
|
5185
|
+
type: "button",
|
|
5186
|
+
className: "sb-conv",
|
|
5187
|
+
onClick: talk,
|
|
5188
|
+
children: [
|
|
5189
|
+
openConversation.unread && /* @__PURE__ */ jsx("span", { className: "sb-dot" }),
|
|
5190
|
+
openConversation.unread && /* @__PURE__ */ jsx("span", {
|
|
5191
|
+
className: "sb-sr",
|
|
5192
|
+
children: S.newReply
|
|
5193
|
+
}),
|
|
5194
|
+
/* @__PURE__ */ jsxs("span", {
|
|
5195
|
+
className: "sb-conv-text",
|
|
5196
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
5197
|
+
className: "sb-conv-title",
|
|
5198
|
+
children: supportStatus(openConversation, company, S)
|
|
5199
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
5200
|
+
className: "sb-conv-line",
|
|
5201
|
+
children: openConversation.lastMessageText
|
|
5202
|
+
})]
|
|
5203
|
+
}),
|
|
5204
|
+
/* @__PURE__ */ jsx(ChevronRightIcon, {})
|
|
5205
|
+
]
|
|
5206
|
+
})
|
|
4165
5207
|
}) : threadItems(messages).map((item) => item.kind === "line" ? /* @__PURE__ */ jsx(TourLine, {
|
|
4166
5208
|
line: item.line,
|
|
4167
5209
|
labels: L,
|
|
@@ -4186,7 +5228,11 @@ function QuestionPanel({ inline, launcher, close, focusBox }) {
|
|
|
4186
5228
|
guide: guideFor(item.response, listed),
|
|
4187
5229
|
words: replyWords(item.response, api.firstName, L),
|
|
4188
5230
|
labels: L,
|
|
4189
|
-
onShowMe: inline ? void 0 : start
|
|
5231
|
+
onShowMe: inline ? void 0 : start,
|
|
5232
|
+
talk: support.available ? {
|
|
5233
|
+
labels: S,
|
|
5234
|
+
onTalk: talk
|
|
5235
|
+
} : void 0
|
|
4190
5236
|
}, item.response.id),
|
|
4191
5237
|
item.response?.kind === "error" && /* @__PURE__ */ jsx(FailureNote, {
|
|
4192
5238
|
message: item.response,
|
|
@@ -4276,17 +5322,21 @@ function replyWords({ reply }, name, labels) {
|
|
|
4276
5322
|
};
|
|
4277
5323
|
}
|
|
4278
5324
|
const SUGGESTION_LIMIT = 4;
|
|
4279
|
-
function Suggestions({ labels, guides, onRow }) {
|
|
5325
|
+
function Suggestions({ labels, guides, onRow, conversation }) {
|
|
4280
5326
|
return /* @__PURE__ */ jsxs("div", {
|
|
4281
5327
|
className: "sb-start",
|
|
4282
|
-
children: [
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
5328
|
+
children: [
|
|
5329
|
+
conversation,
|
|
5330
|
+
/* @__PURE__ */ jsx("h3", {
|
|
5331
|
+
className: "sb-start-title",
|
|
5332
|
+
children: labels.emptyTitle
|
|
5333
|
+
}),
|
|
5334
|
+
guides.length > 0 && /* @__PURE__ */ jsx(Group, {
|
|
5335
|
+
label: labels.commonQuestions,
|
|
5336
|
+
guides: guides.slice(0, SUGGESTION_LIMIT),
|
|
5337
|
+
onRow
|
|
5338
|
+
})
|
|
5339
|
+
]
|
|
4290
5340
|
});
|
|
4291
5341
|
}
|
|
4292
5342
|
function Group({ label, guides, onRow }) {
|
|
@@ -4366,8 +5416,9 @@ function Waiting({ stage, since, labels, productName }) {
|
|
|
4366
5416
|
]
|
|
4367
5417
|
});
|
|
4368
5418
|
}
|
|
4369
|
-
function ReplyArticle({ message, latest, fresh, guide, words, labels, onShowMe }) {
|
|
5419
|
+
function ReplyArticle({ message, latest, fresh, guide, words, labels, onShowMe, talk }) {
|
|
4370
5420
|
const { reply } = message;
|
|
5421
|
+
const notCovered = reply.outcome === "NONE" || reply.outcome === "UNSUPPORTED";
|
|
4371
5422
|
const primary = latest && !message.toured;
|
|
4372
5423
|
const [arrived] = useState(fresh);
|
|
4373
5424
|
return /* @__PURE__ */ jsxs("article", {
|
|
@@ -4396,7 +5447,21 @@ function ReplyArticle({ message, latest, fresh, guide, words, labels, onShowMe }
|
|
|
4396
5447
|
children: [/* @__PURE__ */ jsx(PlayIcon, {}), labels.showMeHow]
|
|
4397
5448
|
})
|
|
4398
5449
|
}),
|
|
4399
|
-
|
|
5450
|
+
talk && (notCovered ? /* @__PURE__ */ jsx("div", {
|
|
5451
|
+
className: "sb-reply-action",
|
|
5452
|
+
children: /* @__PURE__ */ jsxs("button", {
|
|
5453
|
+
type: "button",
|
|
5454
|
+
className: "sb-secondary sb-talk-button",
|
|
5455
|
+
onClick: talk.onTalk,
|
|
5456
|
+
children: [/* @__PURE__ */ jsx(HeadsetIcon, {}), talk.labels.talkToPerson]
|
|
5457
|
+
})
|
|
5458
|
+
}) : /* @__PURE__ */ jsxs("button", {
|
|
5459
|
+
type: "button",
|
|
5460
|
+
className: "sb-talk",
|
|
5461
|
+
onClick: talk.onTalk,
|
|
5462
|
+
children: [/* @__PURE__ */ jsx(HeadsetIcon, {}), talk.labels.talkToPerson]
|
|
5463
|
+
})),
|
|
5464
|
+
!talk && reply.supportUrl && /* @__PURE__ */ jsxs("div", {
|
|
4400
5465
|
className: "sb-support",
|
|
4401
5466
|
children: [/* @__PURE__ */ jsx("span", { children: labels.supportOffer }), /* @__PURE__ */ jsxs("a", {
|
|
4402
5467
|
className: "sb-support-link",
|
|
@@ -4521,19 +5586,6 @@ function Stars({ labels, onPick }) {
|
|
|
4521
5586
|
}, rating))
|
|
4522
5587
|
});
|
|
4523
5588
|
}
|
|
4524
|
-
function clockTime(iso) {
|
|
4525
|
-
const at = new Date(iso);
|
|
4526
|
-
if (Number.isNaN(at.getTime())) return iso;
|
|
4527
|
-
return at.toLocaleTimeString(void 0, {
|
|
4528
|
-
hour: "2-digit",
|
|
4529
|
-
minute: "2-digit"
|
|
4530
|
-
});
|
|
4531
|
-
}
|
|
4532
|
-
function minutesUntil(iso) {
|
|
4533
|
-
const at = new Date(iso).getTime();
|
|
4534
|
-
if (Number.isNaN(at)) return 1;
|
|
4535
|
-
return Math.max(1, Math.round((at - Date.now()) / 6e4));
|
|
4536
|
-
}
|
|
4537
5589
|
//#endregion
|
|
4538
5590
|
//#region src/components/SunboardOverlay.tsx
|
|
4539
5591
|
function SunboardOverlay({ launcher = true } = {}) {
|
|
@@ -4545,4 +5597,4 @@ function SunboardOverlay({ launcher = true } = {}) {
|
|
|
4545
5597
|
] });
|
|
4546
5598
|
}
|
|
4547
5599
|
//#endregion
|
|
4548
|
-
export { DEFAULT_SUNBOARD_API_URL, HttpTransport, LAUNCH_PARAM, Launcher, MemoryTransport, PREVIEW_EXPIRY_STORAGE, PREVIEW_KEY_STORAGE, PREVIEW_PARAM, SunboardOverlay, SunboardProvider, cooldownActive, detectThemeMode, enLabels, hasSeen, isBootstrapOff, markSeen, pickPushGuide, recordDismissal, resolveAnchor, themeToCssVars, useAnchorElement, useComponentSettings, useCurrentPathname, useGuide, useGuidesApi, useLabels, useLauncher, useTheme, useThemeMode, useTrack };
|
|
5600
|
+
export { DEFAULT_SUNBOARD_API_URL, HttpTransport, LAUNCH_PARAM, Launcher, MemoryTransport, PREVIEW_EXPIRY_STORAGE, PREVIEW_KEY_STORAGE, PREVIEW_PARAM, SunboardOverlay, SunboardProvider, cooldownActive, detectThemeMode, enLabels, hasSeen, isBootstrapOff, markSeen, pickPushGuide, recordDismissal, resolveAnchor, themeToCssVars, useAnchorElement, useComponentSettings, useCurrentPathname, useGuide, useGuidesApi, useLabels, useLauncher, useSupport, useTheme, useThemeMode, useTrack };
|