@v-office/website-chat 1.2.0 → 1.3.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.
@@ -1,1396 +0,0 @@
1
- import { i as SESSION_TTL_S, r as MAX_MESSAGE_CHARS } from "./protocol-BuNqZAMt.js";
2
- import { a as House, c as TopicIcon, d as saveConversation, i as Cross, l as clearConversation, n as Check, o as Restart, p as sendMessage, r as Chevron, t as Arrow, u as loadConversation } from "./icons-BK5Lk0cP.js";
3
- import { r as chatCatalog, u as statePath } from "./catalog-ClcCUjf7.js";
4
- import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
5
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
- import { JSONUIProvider, Renderer, defineRegistry, useStateValue } from "@json-render/react";
7
- import { DEFAULT_MAP_STYLE_URL, partyPairs } from "@v-office/website-headless";
8
- //#region src/links.ts
9
- const withQuery = (path, pairs) => `${path}${path.includes("?") ? "&" : "?"}${new URLSearchParams(pairs)}`;
10
- /** The stay's dates, party and chosen rate, as the rental page and the checkout both read them. */
11
- const stayPairs = (stay) => [
12
- ["start", stay.start],
13
- ["end", stay.end],
14
- ...partyPairs(stay.party, "pets").map(([key, value]) => [key, value]),
15
- ...stay.policyId ? [["policy", stay.policyId]] : []
16
- ];
17
- /** See {@link StayHref}. */
18
- const stayHref = (page, stay, searchPath) => {
19
- const path = page ?? searchPath;
20
- return stay ? withQuery(path, stayPairs(stay)) : path;
21
- };
22
- /** See {@link BookingHref}. */
23
- const bookingHref = (bookingPage, stay) => withQuery(bookingPage, [...stayPairs(stay), ...(stay.services ?? []).filter((service) => service.count > 0).map((service) => ["service", `${service.id}:${service.count}`])]);
24
- //#endregion
25
- //#region src/map.tsx
26
- /** MapLibre and its worker, loaded on the first tap. MapLibre finds its worker beside its own module
27
- * by default; bundled, the module is a hashed chunk with nothing beside it, so `?worker&url` has the
28
- * site's bundler emit the worker as one self-contained file (MapLibre's own Vite setup), and
29
- * `setWorkerUrl` points MapLibre at it before the first map reads it. */
30
- const loadMapLibre = () => Promise.all([import("maplibre-gl"), import("maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url")]).then(([library, worker]) => {
31
- library.setWorkerUrl(worker.default);
32
- return library;
33
- });
34
- /** What the style's sources ask to be credited, with their links, as MapLibre's attribution control
35
- * would show it (OpenStreetMap's to its copyright page). */
36
- const Attribution = () => /* @__PURE__ */ jsxs("p", {
37
- className: "map-attribution",
38
- children: [
39
- /* @__PURE__ */ jsx("a", {
40
- href: "https://openfreemap.org",
41
- target: "_blank",
42
- rel: "noopener noreferrer",
43
- children: "OpenFreeMap"
44
- }),
45
- " ",
46
- /* @__PURE__ */ jsx("a", {
47
- href: "https://www.openmaptiles.org/",
48
- target: "_blank",
49
- rel: "noopener noreferrer",
50
- children: "© OpenMapTiles"
51
- }),
52
- " ",
53
- "Data from",
54
- " ",
55
- /* @__PURE__ */ jsx("a", {
56
- href: "https://www.openstreetmap.org/copyright",
57
- target: "_blank",
58
- rel: "noopener noreferrer",
59
- children: "OpenStreetMap"
60
- })
61
- ]
62
- });
63
- const MapCard = ({ rental, label, notice, load, children }) => {
64
- const [shown, setShown] = useState(false);
65
- const box = useRef(null);
66
- const location = rental.location;
67
- useEffect(() => {
68
- const element = box.current;
69
- if (!shown || !element || !location) return;
70
- let removed = false;
71
- let map;
72
- loadMapLibre().then(({ Map, Marker }) => {
73
- if (removed) return;
74
- const center = [location.lng, location.lat];
75
- const created = new Map({
76
- container: element,
77
- style: DEFAULT_MAP_STYLE_URL,
78
- center,
79
- zoom: 13,
80
- attributionControl: false,
81
- scrollZoom: false
82
- });
83
- const pin = document.createElement("span");
84
- pin.className = "map-pin";
85
- new Marker({ element: pin }).setLngLat(center).addTo(created);
86
- map = created;
87
- }).catch(() => {
88
- if (!removed) setShown(false);
89
- });
90
- return () => {
91
- removed = true;
92
- map?.remove();
93
- };
94
- }, [shown, location]);
95
- if (!location) return null;
96
- return /* @__PURE__ */ jsxs("article", {
97
- className: "card map-card",
98
- children: [shown ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
99
- ref: box,
100
- className: "map",
101
- role: "group",
102
- "aria-label": label
103
- }), /* @__PURE__ */ jsx(Attribution, {})] }) : /* @__PURE__ */ jsxs("div", {
104
- className: "map-gate",
105
- children: [/* @__PURE__ */ jsx("p", {
106
- className: "muted",
107
- children: notice
108
- }), /* @__PURE__ */ jsx("button", {
109
- type: "button",
110
- className: "button",
111
- onClick: () => setShown(true),
112
- children: load
113
- })]
114
- }), children]
115
- });
116
- };
117
- //#endregion
118
- //#region src/registry.tsx
119
- const ChatContext = createContext(null);
120
- const useChat = () => {
121
- const value = useContext(ChatContext);
122
- if (!value) throw new Error("A chat component rendered outside the chat panel.");
123
- return value;
124
- };
125
- /** A stay from the state; with a `rentalId`, only a stay of that rental, so a price never shows
126
- * under another rental's name, whatever a spec pairs. */
127
- const useStay = (stayId, rentalId) => {
128
- const stay = useStateValue(statePath("stays", stayId ?? "")) ?? null;
129
- return stay && (rentalId === void 0 || stay.rentalId === rentalId) ? stay : null;
130
- };
131
- const useRental = (rentalId) => useStateValue(statePath("rentals", rentalId)) ?? null;
132
- const day = (value) => /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
133
- /** The locale dates are formatted in: British English ("13 May"), as the replies write dates and as
134
- * European visitors read them, never the US order "5/13" that a bare "en" gives. */
135
- const dateLocale = (locale) => locale === "en" ? "en-GB" : "de-DE";
136
- /** "12.–19. Juli 2027" from two `YYYY-MM-DD` days. */
137
- const period = (stay, locale) => new Intl.DateTimeFormat(dateLocale(locale), {
138
- day: "numeric",
139
- month: "short",
140
- year: "numeric",
141
- timeZone: "UTC"
142
- }).formatRange(day(stay.start), day(stay.end));
143
- /** "14.–21. Juli", "26. Dez.–2. Jan.": the short form, without years even over New Year. */
144
- const shortPeriod = (stay, locale) => {
145
- const short = new Intl.DateTimeFormat(dateLocale(locale), {
146
- day: "numeric",
147
- month: "short",
148
- timeZone: "UTC"
149
- });
150
- return stay.start.slice(0, 4) === stay.end.slice(0, 4) ? short.formatRange(day(stay.start), day(stay.end)) : `${short.format(day(stay.start))}–${short.format(day(stay.end))}`;
151
- };
152
- /** "1 Kind", "2 Kinder": a count in its plural form, its number and noun joined by a no-break space,
153
- * so a narrow card never ends a line on the "2". */
154
- const counted = (forms, n, locale) => (forms[new Intl.PluralRules(locale).select(n)] ?? forms.other).replace("{n} ", `${n}\u00a0`).replace("{n}", String(n));
155
- /** "2 Schlafzimmer" from the PMS, its number and noun joined by a no-break space as in `counted`. */
156
- const keepCount = (fact) => fact.replace(/^(\d+) /, "$1\xA0");
157
- /** A review count as the PMS formats it ("36", or "1 Bewertungen" on v10), as the number it states. */
158
- const reviewCount = (count) => Number(count.replace(/\D/g, ""));
159
- const partyText = (stay, copy, locale) => {
160
- const children = stay.party.children + stay.party.babies;
161
- return [
162
- counted(copy.adults, stay.party.adults, locale),
163
- ...children ? [counted(copy.children, children, locale)] : [],
164
- ...stay.party.pets ? [counted(copy.pets, stay.party.pets, locale)] : []
165
- ].join(", ");
166
- };
167
- /** A bookable stay goes to the rental's booking page with everything chosen, where the site has one;
168
- * otherwise to the rental page (or the search) with the stay filled in. */
169
- const useBookHref = (rentalId, stay) => {
170
- const { searchPath } = useChat();
171
- const page = useStateValue(statePath("pages", rentalId)) ?? null;
172
- const booking = useStateValue(statePath("bookingPages", rentalId)) ?? null;
173
- if (stay?.bookable && booking) return bookingHref(booking, stay);
174
- return stayHref(page, stay?.bookable ? stay : null, searchPath);
175
- };
176
- /** A link out of the chat. It waits while a reply streams: leaving the page then aborts the turn,
177
- * which neither the service nor the widget keeps, and a first conversation would be lost. */
178
- const ReplyLink = ({ href, className, children }) => {
179
- const { busy } = useChat();
180
- return busy ? /* @__PURE__ */ jsx("span", {
181
- className,
182
- "aria-disabled": "true",
183
- children
184
- }) : /* @__PURE__ */ jsx("a", {
185
- className,
186
- href,
187
- children
188
- });
189
- };
190
- /** What a booking link is for, for a screen reader: in a links list, eight "Buchen" say nothing. */
191
- const LinkTarget = ({ rentalId, stay }) => {
192
- const { locale } = useChat();
193
- const rental = useRental(rentalId);
194
- if (!rental) return null;
195
- return /* @__PURE__ */ jsx("span", {
196
- className: "sr-only",
197
- children: `, ${rental.name}${stay?.bookable ? `, ${shortPeriod(stay, locale)}` : ""}`
198
- });
199
- };
200
- const BookLink = ({ rentalId, stay }) => {
201
- const { copy } = useChat();
202
- return /* @__PURE__ */ jsxs(ReplyLink, {
203
- className: "button",
204
- href: useBookHref(rentalId, stay),
205
- children: [stay?.bookable ? copy.bookStay : copy.viewRental, /* @__PURE__ */ jsx(LinkTarget, {
206
- rentalId,
207
- stay
208
- })]
209
- });
210
- };
211
- /** A row that snaps item by item, with arrow buttons and "n / total"; swipe, scroll or keys move it. */
212
- const Snapper = ({ count, className, label, children }) => {
213
- const { copy } = useChat();
214
- const row = useRef(null);
215
- const [index, setIndex] = useState(0);
216
- const range = (element) => Math.max(1, element.scrollWidth - element.clientWidth);
217
- const go = (step) => {
218
- const element = row.current;
219
- if (!element) return;
220
- const still = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
221
- const [first, second] = element.children;
222
- const card = first && second ? second.offsetLeft - first.offsetLeft : element.clientWidth;
223
- element.scrollBy({
224
- left: step * card,
225
- behavior: still ? "auto" : "smooth"
226
- });
227
- };
228
- return /* @__PURE__ */ jsxs("div", {
229
- className: `${className}-wrap`,
230
- role: "group",
231
- "aria-roledescription": "carousel",
232
- "aria-label": label,
233
- children: [/* @__PURE__ */ jsx("div", {
234
- ref: row,
235
- className,
236
- onScroll: (event) => {
237
- const element = event.currentTarget;
238
- setIndex(Math.round(element.scrollLeft / range(element) * (count - 1)));
239
- },
240
- children
241
- }), count > 1 && /* @__PURE__ */ jsxs("div", {
242
- className: "snap-nav",
243
- children: [
244
- /* @__PURE__ */ jsx("button", {
245
- type: "button",
246
- "aria-label": copy.previous,
247
- disabled: index === 0,
248
- onClick: () => go(-1),
249
- children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
250
- }),
251
- /* @__PURE__ */ jsxs("span", {
252
- className: "muted",
253
- children: [
254
- Math.min(index + 1, count),
255
- " / ",
256
- count
257
- ]
258
- }),
259
- /* @__PURE__ */ jsx("button", {
260
- type: "button",
261
- "aria-label": copy.next,
262
- disabled: index >= count - 1,
263
- onClick: () => go(1),
264
- children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
265
- })
266
- ]
267
- })]
268
- });
269
- };
270
- /** A rental's photos, swiped or stepped through with the arrows a pointer brings up; the dots say
271
- * which one shows. */
272
- const Gallery = ({ images, name }) => {
273
- const { copy } = useChat();
274
- const row = useRef(null);
275
- const [index, setIndex] = useState(0);
276
- if (images.length === 0) return null;
277
- const go = (to) => {
278
- const element = row.current;
279
- if (!element) return;
280
- const still = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
281
- element.scrollTo({
282
- left: to * element.clientWidth,
283
- behavior: still ? "auto" : "smooth"
284
- });
285
- };
286
- const many = images.length > 1;
287
- return /* @__PURE__ */ jsxs("div", {
288
- className: "gallery-wrap",
289
- role: "group",
290
- "aria-roledescription": "carousel",
291
- "aria-label": name,
292
- children: [/* @__PURE__ */ jsx("div", {
293
- ref: row,
294
- className: "gallery",
295
- onScroll: (event) => {
296
- const element = event.currentTarget;
297
- setIndex(Math.round(element.scrollLeft / Math.max(1, element.clientWidth)));
298
- },
299
- children: images.map((image, imageIndex) => /* @__PURE__ */ jsx("img", {
300
- src: image.src,
301
- alt: image.alt || `${name}, ${copy.photo} ${imageIndex + 1}`,
302
- loading: "lazy"
303
- }, image.src))
304
- }), many && /* @__PURE__ */ jsxs(Fragment, { children: [
305
- /* @__PURE__ */ jsx("button", {
306
- type: "button",
307
- className: "gallery-arrow",
308
- "data-side": "prev",
309
- "aria-label": copy.previous,
310
- disabled: index === 0,
311
- onClick: () => go(index - 1),
312
- children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
313
- }),
314
- /* @__PURE__ */ jsx("button", {
315
- type: "button",
316
- className: "gallery-arrow",
317
- "data-side": "next",
318
- "aria-label": copy.next,
319
- disabled: index >= images.length - 1,
320
- onClick: () => go(index + 1),
321
- children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
322
- }),
323
- /* @__PURE__ */ jsx("span", {
324
- className: "dots",
325
- "aria-hidden": "true",
326
- children: images.map((image, imageIndex) => /* @__PURE__ */ jsx("i", { "data-current": imageIndex === index || void 0 }, image.src))
327
- }),
328
- /* @__PURE__ */ jsx("span", {
329
- className: "sr-only",
330
- children: `${copy.photo} ${Math.min(index + 1, images.length)} / ${images.length}`
331
- })
332
- ] })]
333
- });
334
- };
335
- /** The PMS's score as a chip; its number of reviews is read out, not shown. */
336
- const Rating = ({ rental }) => {
337
- const { copy, locale } = useChat();
338
- if (!rental.rating) return null;
339
- const { value, count } = rental.rating;
340
- return /* @__PURE__ */ jsx("span", {
341
- className: "score-chip",
342
- role: "img",
343
- title: counted(copy.reviews, reviewCount(count), locale),
344
- "aria-label": `${value}, ${counted(copy.reviews, reviewCount(count), locale)}`,
345
- children: value
346
- });
347
- };
348
- /** The visitor's wishes the latest search that found the rental applied, which it therefore has. */
349
- const useWishes = (rentalId) => useStateValue(statePath("wishes", rentalId)) ?? [];
350
- const Wishes = ({ wishes }) => {
351
- const { copy } = useChat();
352
- if (wishes.length === 0) return null;
353
- return /* @__PURE__ */ jsx("ul", {
354
- className: "wishes",
355
- "aria-label": copy.matchesWishes,
356
- children: wishes.map((wish) => /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx(Check, {}), wish] }, wish))
357
- });
358
- };
359
- /** "7 Nächte · 6.–13. Mai": a stay's length and dates, short. */
360
- const stayMeta = (stay, copy, locale) => {
361
- const nights = Math.round((day(stay.end).getTime() - day(stay.start).getTime()) / 864e5);
362
- return /* @__PURE__ */ jsxs(Fragment, { children: [
363
- counted(copy.nights, nights, locale),
364
- " ·",
365
- " ",
366
- /* @__PURE__ */ jsx("span", {
367
- className: "period",
368
- children: shortPeriod(stay, locale)
369
- })
370
- ] });
371
- };
372
- /** A card's foot: the stay's total, its length and dates and the party it is for, and the way to
373
- * book or to the rental. */
374
- const CardFoot = ({ rentalId, stay }) => {
375
- const { copy, locale } = useChat();
376
- const href = useBookHref(rentalId, stay);
377
- return /* @__PURE__ */ jsxs("div", {
378
- className: "card-foot",
379
- children: [stay && /* @__PURE__ */ jsx("div", {
380
- className: "foot-price",
381
- children: !stay.bookable ? /* @__PURE__ */ jsx("span", {
382
- className: "foot-meta",
383
- children: copy.notBookable
384
- }) : /* @__PURE__ */ jsxs(Fragment, { children: [
385
- stay.total && /* @__PURE__ */ jsx("span", {
386
- className: "price",
387
- children: stay.total
388
- }),
389
- /* @__PURE__ */ jsx("span", {
390
- className: "foot-meta",
391
- children: stayMeta(stay, copy, locale)
392
- }),
393
- /* @__PURE__ */ jsx("span", {
394
- className: "foot-meta",
395
- children: partyText(stay, copy, locale)
396
- })
397
- ] })
398
- }), /* @__PURE__ */ jsxs(ReplyLink, {
399
- className: "button compact",
400
- href,
401
- children: [stay?.bookable ? copy.book : copy.view, /* @__PURE__ */ jsx(LinkTarget, {
402
- rentalId,
403
- stay
404
- })]
405
- })]
406
- });
407
- };
408
- const Card = ({ rentalId, stayId }) => {
409
- const rental = useRental(rentalId);
410
- const stay = useStay(stayId, rentalId);
411
- const wishes = useWishes(rentalId);
412
- if (!rental) return null;
413
- return /* @__PURE__ */ jsxs("article", {
414
- className: "card rental-card",
415
- children: [
416
- rental.images.length > 0 ? /* @__PURE__ */ jsx(Gallery, {
417
- images: rental.images,
418
- name: rental.name
419
- }) : /* @__PURE__ */ jsx("div", {
420
- className: "no-photo",
421
- "aria-hidden": "true",
422
- children: /* @__PURE__ */ jsx(House, {})
423
- }),
424
- /* @__PURE__ */ jsxs("div", {
425
- className: "card-body",
426
- children: [
427
- /* @__PURE__ */ jsxs("div", {
428
- className: "card-meta",
429
- children: [rental.place && /* @__PURE__ */ jsx("span", {
430
- className: "place",
431
- children: rental.place
432
- }), /* @__PURE__ */ jsx(Rating, { rental })]
433
- }),
434
- /* @__PURE__ */ jsx("h3", {
435
- className: "card-title",
436
- children: rental.name
437
- }),
438
- rental.highlights.length > 0 && /* @__PURE__ */ jsx("p", {
439
- className: "specs",
440
- children: rental.highlights.slice(0, 3).map(keepCount).join(" · ")
441
- }),
442
- /* @__PURE__ */ jsx(Wishes, { wishes })
443
- ]
444
- }),
445
- /* @__PURE__ */ jsx(CardFoot, {
446
- rentalId: rental.id,
447
- stay
448
- })
449
- ]
450
- });
451
- };
452
- /** Monday-first weeks of one month, as `YYYY-MM-DD` days and `null` padding. */
453
- const monthGrid = (month) => {
454
- const first = day(`${month}-01`);
455
- const days = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
456
- const lead = (first.getUTCDay() + 6) % 7;
457
- return [...Array.from({ length: lead }, () => null), ...Array.from({ length: days }, (_, i) => `${month}-${String(i + 1).padStart(2, "0")}`)];
458
- };
459
- const monthsOf = (calendar) => {
460
- const months = [];
461
- for (let month = calendar.from.slice(0, 7); month <= calendar.through.slice(0, 7);) {
462
- months.push(month);
463
- const next = day(`${month}-01`);
464
- next.setUTCMonth(next.getUTCMonth() + 1);
465
- month = next.toISOString().slice(0, 7);
466
- }
467
- return months;
468
- };
469
- const Calendar = ({ rentalId, stayId }) => {
470
- const { copy, locale } = useChat();
471
- const rental = useRental(rentalId);
472
- const stay = useStay(stayId, rentalId);
473
- const calendar = useStateValue(statePath("availability", rentalId)) ?? null;
474
- const months = calendar ? monthsOf(calendar) : [];
475
- const [shown, setShown] = useState(() => Math.max(0, stay ? months.indexOf(stay.start.slice(0, 7)) : 0));
476
- if (!calendar || months.length === 0) return null;
477
- const month = months[Math.min(shown, months.length - 1)];
478
- const weekdays = Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(dateLocale(locale), {
479
- weekday: "short",
480
- timeZone: "UTC"
481
- }).format(day(`2024-01-0${i + 1}`)));
482
- const title = new Intl.DateTimeFormat(dateLocale(locale), {
483
- month: "long",
484
- year: "numeric",
485
- timeZone: "UTC"
486
- }).format(day(`${month}-01`));
487
- const inStay = (date) => Boolean(stay && date >= stay.start && date <= stay.end);
488
- const fullDate = new Intl.DateTimeFormat(dateLocale(locale), {
489
- weekday: "long",
490
- day: "numeric",
491
- month: "long",
492
- timeZone: "UTC"
493
- });
494
- const statusText = {
495
- free: copy.free,
496
- booked: copy.booked,
497
- closed: copy.closed
498
- };
499
- return /* @__PURE__ */ jsxs("div", {
500
- className: "calendar",
501
- children: [
502
- /* @__PURE__ */ jsxs("div", {
503
- className: "calendar-head",
504
- children: [
505
- /* @__PURE__ */ jsx("button", {
506
- type: "button",
507
- "aria-label": copy.previous,
508
- disabled: shown === 0,
509
- onClick: () => setShown(shown - 1),
510
- children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
511
- }),
512
- /* @__PURE__ */ jsxs("span", { children: [title, rental ? ` · ${rental.name}` : ""] }),
513
- /* @__PURE__ */ jsx("button", {
514
- type: "button",
515
- "aria-label": copy.next,
516
- disabled: shown >= months.length - 1,
517
- onClick: () => setShown(shown + 1),
518
- children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
519
- })
520
- ]
521
- }),
522
- /* @__PURE__ */ jsxs("div", {
523
- className: "calendar-grid",
524
- children: [weekdays.map((weekday) => /* @__PURE__ */ jsx("span", {
525
- className: "weekday",
526
- children: weekday
527
- }, weekday)), monthGrid(month).map((date, index) => {
528
- if (!date) return /* @__PURE__ */ jsx("span", {}, `pad-${index}`);
529
- const status = calendar.days[date] ?? "closed";
530
- return /* @__PURE__ */ jsxs("span", {
531
- className: "day",
532
- "data-status": status,
533
- "data-stay": inStay(date) || void 0,
534
- children: [/* @__PURE__ */ jsx("span", {
535
- "aria-hidden": "true",
536
- children: Number(date.slice(8))
537
- }), /* @__PURE__ */ jsx("span", {
538
- className: "sr-only",
539
- children: [
540
- fullDate.format(day(date)),
541
- statusText[status],
542
- ...inStay(date) ? [copy.yourStay] : []
543
- ].join(", ")
544
- })]
545
- }, date);
546
- })]
547
- }),
548
- /* @__PURE__ */ jsxs("div", {
549
- className: "legend muted",
550
- children: [
551
- /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-status": "free" }), copy.free] }),
552
- /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-status": "booked" }), copy.booked] }),
553
- stay && /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-stay": "true" }), copy.yourStay] })
554
- ]
555
- })
556
- ]
557
- });
558
- };
559
- const CompareColumn = ({ rentalId, stayId }) => {
560
- const { copy, locale } = useChat();
561
- const rental = useRental(rentalId);
562
- const stay = useStay(stayId, rentalId);
563
- const href = useBookHref(rentalId, stay);
564
- if (!rental) return null;
565
- const rooms = rental.rooms.slice(0, 3);
566
- const highlights = rental.highlights.filter((highlight) => !rooms.includes(highlight));
567
- return /* @__PURE__ */ jsxs("div", {
568
- className: "compare-col",
569
- children: [
570
- /* @__PURE__ */ jsx("strong", { children: rental.name }),
571
- /* @__PURE__ */ jsxs("dl", { children: [
572
- /* @__PURE__ */ jsx("dt", { children: copy.compareRating }),
573
- /* @__PURE__ */ jsx("dd", { children: rental.rating ? `★ ${rental.rating.value} (${counted(copy.reviews, reviewCount(rental.rating.count), locale)})` : "–" }),
574
- /* @__PURE__ */ jsx("dt", { children: copy.compareRooms }),
575
- /* @__PURE__ */ jsx("dd", { children: rooms.map(keepCount).join(", ") || "–" }),
576
- /* @__PURE__ */ jsx("dt", { children: copy.compareHighlights }),
577
- /* @__PURE__ */ jsx("dd", { children: highlights.slice(0, 4).map(keepCount).join(", ") || "–" }),
578
- stay && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("dt", { children: copy.compareStay }), /* @__PURE__ */ jsxs("dd", { children: [
579
- shortPeriod(stay, locale),
580
- /* @__PURE__ */ jsx("br", {}),
581
- /* @__PURE__ */ jsx("span", {
582
- className: "muted",
583
- children: partyText(stay, copy, locale)
584
- }),
585
- /* @__PURE__ */ jsx("br", {}),
586
- !stay.bookable ? /* @__PURE__ */ jsx("span", {
587
- className: "muted",
588
- children: copy.notBookable
589
- }) : stay.total && /* @__PURE__ */ jsx("span", {
590
- className: "price",
591
- children: stay.total
592
- })
593
- ] })] })
594
- ] }),
595
- /* @__PURE__ */ jsxs(ReplyLink, {
596
- className: "button",
597
- href,
598
- children: [stay?.bookable ? copy.book : copy.view, /* @__PURE__ */ jsx(LinkTarget, {
599
- rentalId,
600
- stay
601
- })]
602
- })
603
- ]
604
- });
605
- };
606
- const AlternativeChip = ({ stay }) => {
607
- const { locale } = useChat();
608
- const href = useBookHref(stay.rentalId, stay);
609
- return /* @__PURE__ */ jsx(ReplyLink, {
610
- className: "chip-link",
611
- href,
612
- children: /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("strong", { children: shortPeriod(stay, locale) }), stay.total ? ` · ${stay.total}` : ""] })
613
- });
614
- };
615
- /** One arrival of a price calendar: its bar, the PMS's total, the day; tapping asks for that stay. */
616
- const PriceColumn = ({ rentalId, option, asked, cheapest }) => {
617
- const { copy, locale, send, busy } = useChat();
618
- const rental = useRental(rentalId);
619
- const stay = useStay(option.stayId, rentalId);
620
- const free = Boolean(stay?.bookable && stay.total);
621
- const arrival = (options) => new Intl.DateTimeFormat(dateLocale(locale), {
622
- ...options,
623
- timeZone: "UTC"
624
- }).format(day(option.start));
625
- const dates = period(option, locale);
626
- return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
627
- type: "button",
628
- className: "price-column",
629
- "data-asked": asked || void 0,
630
- "data-cheapest": cheapest || void 0,
631
- "data-free": free || void 0,
632
- disabled: !free || !send || busy,
633
- "aria-label": [
634
- dates,
635
- free ? stay.total : copy.closed,
636
- ...cheapest ? [copy.cheapest] : [],
637
- ...asked ? [copy.yourStay] : []
638
- ].join(", "),
639
- onClick: () => send?.(rental ? `${rental.name}: ${dates}` : dates),
640
- children: [
641
- /* @__PURE__ */ jsx("span", {
642
- className: "bar-track",
643
- "aria-hidden": "true",
644
- children: free && /* @__PURE__ */ jsx("span", {
645
- className: "bar",
646
- style: { height: `${25 + 75 * (option.level ?? 1)}%` }
647
- })
648
- }),
649
- /* @__PURE__ */ jsx("span", {
650
- className: "price-text",
651
- "aria-hidden": "true",
652
- children: free ? stay.total : copy.closed
653
- }),
654
- /* @__PURE__ */ jsxs("span", {
655
- className: "muted",
656
- "aria-hidden": "true",
657
- children: [
658
- arrival({ weekday: "short" }),
659
- /* @__PURE__ */ jsx("br", {}),
660
- arrival({
661
- day: "numeric",
662
- month: "short"
663
- })
664
- ]
665
- })
666
- ]
667
- }) });
668
- };
669
- /** Which ends of a scrolled row have more beyond them, for the fade there. */
670
- const hiddenEdges = (row) => {
671
- const start = row.scrollLeft > 1;
672
- const end = row.scrollLeft + row.clientWidth < row.scrollWidth - 1;
673
- return start && end ? "both" : start ? "start" : end ? "end" : void 0;
674
- };
675
- const PriceCalendarView = ({ rentalId }) => {
676
- const { copy, locale } = useChat();
677
- const rental = useRental(rentalId);
678
- const calendar = useStateValue(statePath("priceCalendars", rentalId)) ?? null;
679
- const row = useRef(null);
680
- const [fade, setFade] = useState();
681
- useEffect(() => {
682
- const element = row.current;
683
- if (!element || !calendar) return;
684
- const cheapest = calendar.options.findIndex((option) => option.stayId !== null && option.stayId === calendar.cheapest);
685
- const asked = calendar.options.findIndex((option) => option.start === calendar.asked);
686
- const column = element.children[cheapest >= 0 ? cheapest : asked];
687
- if (column) {
688
- const end = column.offsetLeft + column.offsetWidth;
689
- if (end > element.clientWidth) element.scrollLeft = end - element.clientWidth;
690
- }
691
- setFade(hiddenEdges(element));
692
- }, [calendar]);
693
- if (!calendar || calendar.options.length === 0) return null;
694
- return /* @__PURE__ */ jsx("section", {
695
- className: "card price-calendar",
696
- "aria-label": copy.priceCalendar,
697
- children: /* @__PURE__ */ jsxs("div", {
698
- className: "card-body",
699
- children: [
700
- /* @__PURE__ */ jsx("h3", {
701
- className: "card-title",
702
- children: copy.priceCalendar
703
- }),
704
- /* @__PURE__ */ jsxs("span", {
705
- className: "muted",
706
- children: [counted(copy.priceCalendarNights, calendar.nights, locale), rental ? ` · ${rental.name}` : ""]
707
- }),
708
- /* @__PURE__ */ jsx("ul", {
709
- ref: row,
710
- className: "price-columns",
711
- "data-fade": fade,
712
- onScroll: (event) => setFade(hiddenEdges(event.currentTarget)),
713
- children: calendar.options.map((option) => /* @__PURE__ */ jsx(PriceColumn, {
714
- rentalId,
715
- option,
716
- asked: option.start === calendar.asked,
717
- cheapest: option.stayId !== null && option.stayId === calendar.cheapest
718
- }, option.start))
719
- }),
720
- /* @__PURE__ */ jsxs("div", {
721
- className: "legend muted",
722
- children: [/* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-cheapest": "true" }), copy.cheapest] }), /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-asked": "true" }), copy.yourStay] })]
723
- })
724
- ]
725
- })
726
- });
727
- };
728
- const ReviewsView = ({ rentalId }) => {
729
- const { copy, locale } = useChat();
730
- const rental = useRental(rentalId);
731
- const reviews = useStateValue(statePath("reviews", rentalId)) ?? null;
732
- if (!reviews) return null;
733
- return /* @__PURE__ */ jsx("section", {
734
- className: "card reviews",
735
- "aria-label": copy.reviewsTitle,
736
- children: /* @__PURE__ */ jsxs("div", {
737
- className: "card-body",
738
- children: [
739
- /* @__PURE__ */ jsxs("h3", {
740
- className: "card-title",
741
- children: [copy.reviewsTitle, rental ? ` · ${rental.name}` : ""]
742
- }),
743
- /* @__PURE__ */ jsxs("div", {
744
- className: "score",
745
- children: [/* @__PURE__ */ jsx("span", {
746
- className: "score-value",
747
- children: reviews.rating
748
- }), /* @__PURE__ */ jsxs("span", { children: [reviews.grade && /* @__PURE__ */ jsx("strong", { children: reviews.grade }), /* @__PURE__ */ jsxs("span", {
749
- className: "muted",
750
- children: [" ", counted(copy.reviews, reviewCount(reviews.count), locale)]
751
- })] })]
752
- }),
753
- reviews.categories.length > 0 && /* @__PURE__ */ jsx("dl", {
754
- className: "review-categories",
755
- children: reviews.categories.map((category) => /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", { children: category.label }), /* @__PURE__ */ jsx("dd", { children: category.rating })] }, category.label))
756
- }),
757
- reviews.excerpts.map((excerpt) => /* @__PURE__ */ jsx("blockquote", {
758
- className: "excerpt",
759
- children: /* @__PURE__ */ jsx("p", { children: excerpt.text })
760
- }, excerpt.text))
761
- ]
762
- })
763
- });
764
- };
765
- const FactsView = ({ rentalId, groups }) => {
766
- const rental = useRental(rentalId);
767
- const known = useStateValue(statePath("facts", rentalId)) ?? [];
768
- const shown = groups.flatMap((label) => {
769
- const group = known.find((candidate) => candidate.label.toLowerCase() === label.toLowerCase());
770
- return group ? [group] : [];
771
- });
772
- if (!rental || shown.length === 0) return null;
773
- return /* @__PURE__ */ jsx("section", {
774
- className: "card facts",
775
- "aria-label": rental.name,
776
- children: /* @__PURE__ */ jsxs("div", {
777
- className: "card-body",
778
- children: [/* @__PURE__ */ jsx("h3", {
779
- className: "card-title",
780
- children: rental.name
781
- }), shown.map((group) => /* @__PURE__ */ jsxs("div", {
782
- className: "fact-group",
783
- children: [/* @__PURE__ */ jsx("h4", { children: group.label }), /* @__PURE__ */ jsx("ul", { children: group.items.map((item) => /* @__PURE__ */ jsx("li", { children: item }, item)) })]
784
- }, group.label))]
785
- })
786
- });
787
- };
788
- const RentalMapView = ({ rentalId }) => {
789
- const { copy } = useChat();
790
- const rental = useRental(rentalId);
791
- const href = useBookHref(rentalId, null);
792
- if (!rental) return null;
793
- return /* @__PURE__ */ jsx(MapCard, {
794
- rental,
795
- label: copy.mapLabel.replace("{name}", rental.name),
796
- notice: copy.mapNotice,
797
- load: copy.mapLoad,
798
- children: /* @__PURE__ */ jsxs("div", {
799
- className: "card-body",
800
- children: [
801
- /* @__PURE__ */ jsx("h3", {
802
- className: "card-title",
803
- children: rental.name
804
- }),
805
- rental.place && /* @__PURE__ */ jsx("span", {
806
- className: "muted",
807
- children: rental.place
808
- }),
809
- /* @__PURE__ */ jsx(ReplyLink, {
810
- className: "button",
811
- href,
812
- children: copy.viewRental
813
- })
814
- ]
815
- })
816
- });
817
- };
818
- /** The registry the panel renders specs with. */
819
- const { registry } = defineRegistry(chatCatalog, { components: {
820
- Stack: ({ children }) => /* @__PURE__ */ jsx("div", {
821
- className: "ui",
822
- children
823
- }),
824
- RentalCard: ({ props }) => /* @__PURE__ */ jsx(Card, {
825
- rentalId: props.rentalId,
826
- stayId: props.stayId
827
- }),
828
- RentalCarousel: ({ props }) => {
829
- const { copy } = useChat();
830
- return /* @__PURE__ */ jsx(Snapper, {
831
- count: props.items.length,
832
- className: "carousel",
833
- label: copy.rentalsFound,
834
- children: props.items.map((item) => /* @__PURE__ */ jsx(Card, {
835
- rentalId: item.rentalId,
836
- stayId: item.stayId
837
- }, `${item.rentalId}:${item.stayId}`))
838
- });
839
- },
840
- StayQuote: ({ props }) => {
841
- const stay = useStay(props.stayId);
842
- const rental = useRental(stay?.rentalId ?? "");
843
- if (!stay) return null;
844
- return /* @__PURE__ */ jsxs("article", {
845
- className: "card stay-card",
846
- children: [/* @__PURE__ */ jsx("div", {
847
- className: "card-body",
848
- children: rental && /* @__PURE__ */ jsx("h3", {
849
- className: "card-title",
850
- children: rental.name
851
- })
852
- }), /* @__PURE__ */ jsx(CardFoot, {
853
- rentalId: stay.rentalId,
854
- stay
855
- })]
856
- });
857
- },
858
- PriceBreakdown: ({ props }) => {
859
- const { copy, locale } = useChat();
860
- const stay = useStay(props.stayId);
861
- const rental = useRental(stay?.rentalId ?? "");
862
- if (!stay?.bookable || !stay.total) return null;
863
- const label = rental ? `${copy.priceBreakdown} · ${rental.name}` : copy.priceBreakdown;
864
- return /* @__PURE__ */ jsx("section", {
865
- className: "card breakdown",
866
- "aria-label": label,
867
- children: /* @__PURE__ */ jsxs("div", {
868
- className: "card-body",
869
- children: [
870
- /* @__PURE__ */ jsx("span", {
871
- className: "muted",
872
- children: label
873
- }),
874
- /* @__PURE__ */ jsxs("span", {
875
- className: "foot-meta",
876
- children: [
877
- stayMeta(stay, copy, locale),
878
- " · ",
879
- partyText(stay, copy, locale)
880
- ]
881
- }),
882
- stay.lines.map((line) => /* @__PURE__ */ jsxs("div", {
883
- className: "line",
884
- children: [/* @__PURE__ */ jsx("span", { children: line.label }), /* @__PURE__ */ jsx("span", { children: line.amount })]
885
- }, `${line.label}:${line.amount}`)),
886
- /* @__PURE__ */ jsxs("div", {
887
- className: "line total",
888
- children: [/* @__PURE__ */ jsx("span", { children: copy.total }), /* @__PURE__ */ jsx("span", { children: stay.total })]
889
- })
890
- ]
891
- })
892
- });
893
- },
894
- AlternativeStays: ({ props }) => {
895
- const { copy } = useChat();
896
- const rental = useRental(props.rentalId);
897
- const stays = useStateValue("/stays") ?? {};
898
- const bookable = props.stayIds.flatMap((stayId) => {
899
- const stay = stays[stayId];
900
- return stay?.rentalId === props.rentalId && stay.bookable ? [stay] : [];
901
- });
902
- if (bookable.length === 0) return null;
903
- const label = rental ? `${copy.otherDates} · ${rental.name}` : copy.otherDates;
904
- return /* @__PURE__ */ jsxs("div", {
905
- className: "alternatives",
906
- role: "group",
907
- "aria-label": label,
908
- children: [/* @__PURE__ */ jsx("p", {
909
- className: "alt-caption",
910
- "aria-hidden": "true",
911
- children: label
912
- }), /* @__PURE__ */ jsx("div", {
913
- className: "alt-chips",
914
- children: bookable.map((stay) => /* @__PURE__ */ jsx(AlternativeChip, { stay }, stay.id))
915
- })]
916
- });
917
- },
918
- AvailabilityCalendar: ({ props }) => /* @__PURE__ */ jsx(Calendar, {
919
- rentalId: props.rentalId,
920
- stayId: props.stayId
921
- }),
922
- PriceCalendar: ({ props }) => /* @__PURE__ */ jsx(PriceCalendarView, { rentalId: props.rentalId }),
923
- ReviewSummary: ({ props }) => /* @__PURE__ */ jsx(ReviewsView, { rentalId: props.rentalId }),
924
- RentalFacts: ({ props }) => /* @__PURE__ */ jsx(FactsView, {
925
- rentalId: props.rentalId,
926
- groups: props.groups
927
- }),
928
- RentalMap: ({ props }) => /* @__PURE__ */ jsx(RentalMapView, { rentalId: props.rentalId }),
929
- Compare: ({ props }) => /* @__PURE__ */ jsx("div", {
930
- className: "compare",
931
- children: props.items.map((item) => /* @__PURE__ */ jsx(CompareColumn, {
932
- rentalId: item.rentalId,
933
- stayId: item.stayId
934
- }, `${item.rentalId}:${item.stayId}`))
935
- }),
936
- StayLink: ({ props }) => {
937
- const stay = useStay(props.stayId, props.rentalId);
938
- return /* @__PURE__ */ jsx(BookLink, {
939
- rentalId: props.rentalId,
940
- stay
941
- });
942
- },
943
- ContactLink: () => {
944
- const { copy, contactPath } = useChat();
945
- return /* @__PURE__ */ jsx(ReplyLink, {
946
- className: "link",
947
- href: contactPath,
948
- children: copy.contactLink
949
- });
950
- },
951
- QuickReplies: ({ props }) => {
952
- const { send, busy } = useChat();
953
- if (!send) return null;
954
- return /* @__PURE__ */ jsx("div", {
955
- className: "quick-replies",
956
- children: props.options.map((option) => /* @__PURE__ */ jsx("button", {
957
- type: "button",
958
- className: "chip",
959
- disabled: busy,
960
- onClick: () => send(option),
961
- children: option
962
- }, option))
963
- });
964
- }
965
- } });
966
- //#endregion
967
- //#region src/panel.tsx
968
- /** Appends one event's content to the reply being streamed (the last message). */
969
- const appendToReply = (messages, part) => {
970
- const last = messages.at(-1);
971
- if (!last || last.role !== "assistant") return [...messages, {
972
- role: "assistant",
973
- parts: [part],
974
- unconfirmed: true
975
- }];
976
- const tail = last.parts.at(-1);
977
- const parts = part.kind === "text" && tail?.kind === "text" ? [...last.parts.slice(0, -1), {
978
- kind: "text",
979
- text: tail.text + part.text
980
- }] : [...last.parts, part];
981
- return [...messages.slice(0, -1), {
982
- ...last,
983
- parts
984
- }];
985
- };
986
- /** The service finished the turn: its messages, from its visitor message on, are the
987
- * conversation's now. An earlier turn it refused stays unconfirmed, on this page only. */
988
- const confirmed = (messages) => {
989
- const turn = messages.findLastIndex((message) => message.role === "visitor");
990
- return messages.map((message, index) => index >= turn && message.unconfirmed ? {
991
- role: message.role,
992
- parts: message.parts
993
- } : message);
994
- };
995
- const SCROLL_LOCK = "data-v-office-chat-open";
996
- /** Locks the page's scroll until the returned function runs. The rule lives in the page's head (a
997
- * shadow root's styles cannot reach <html>) and is added once. */
998
- const lockPageScroll = () => {
999
- const root = document.documentElement;
1000
- if (!document.getElementById(SCROLL_LOCK)) {
1001
- const style = document.createElement("style");
1002
- style.id = SCROLL_LOCK;
1003
- style.textContent = `html[${SCROLL_LOCK}] { overflow: hidden; }`;
1004
- document.head.append(style);
1005
- }
1006
- root.setAttribute(SCROLL_LOCK, "");
1007
- return () => root.removeAttribute(SCROLL_LOCK);
1008
- };
1009
- /** The assistant's text, its `**bold**` (a rental's name, the dates) as strong text: nothing else
1010
- * is read as markup, so the model's text never becomes HTML. */
1011
- const RichText = ({ text }) => /* @__PURE__ */ jsx(Fragment, { children: text.split(/\*\*(.+?)\*\*/g).map((piece, index) => index % 2 === 1 ? /* @__PURE__ */ jsx("strong", { children: piece }, index) : piece) });
1012
- const SpecView = ({ spec }) => /* @__PURE__ */ jsx(JSONUIProvider, {
1013
- registry,
1014
- initialState: spec.state ?? {},
1015
- children: /* @__PURE__ */ jsx(Renderer, {
1016
- spec,
1017
- registry
1018
- })
1019
- });
1020
- const Consent = ({ copy, pending, onAccept, buttonRef }) => /* @__PURE__ */ jsxs("div", {
1021
- className: "consent",
1022
- children: [
1023
- /* @__PURE__ */ jsx("p", {
1024
- className: "message",
1025
- "data-role": "assistant",
1026
- children: copy.greeting
1027
- }),
1028
- pending && /* @__PURE__ */ jsxs("figure", {
1029
- className: "pending",
1030
- children: [/* @__PURE__ */ jsx("figcaption", {
1031
- className: "muted",
1032
- children: copy.yourMessage
1033
- }), /* @__PURE__ */ jsx("p", { children: pending })]
1034
- }),
1035
- /* @__PURE__ */ jsxs("div", {
1036
- className: "consent-card",
1037
- children: [/* @__PURE__ */ jsx("p", { children: copy.consent }), /* @__PURE__ */ jsx("button", {
1038
- ref: buttonRef,
1039
- type: "button",
1040
- className: "button compact",
1041
- onClick: onAccept,
1042
- children: copy.consentAccept
1043
- })]
1044
- })
1045
- ]
1046
- });
1047
- /** Shown until the reply's text: a search takes seconds, and three dots alone read as nothing.
1048
- * Hidden from a screen reader: inside the busy log it may go unannounced, so the panel's own status,
1049
- * outside the log, says it. */
1050
- const Thinking = ({ label }) => /* @__PURE__ */ jsxs("div", {
1051
- className: "thinking",
1052
- "aria-hidden": "true",
1053
- children: [
1054
- /* @__PURE__ */ jsx("i", {}),
1055
- /* @__PURE__ */ jsx("i", {}),
1056
- /* @__PURE__ */ jsx("i", {}),
1057
- /* @__PURE__ */ jsx("span", {
1058
- className: "thinking-label",
1059
- children: label
1060
- })
1061
- ]
1062
- });
1063
- /** Why the chat cannot answer, as its reply to the visitor's message, and where to go instead. */
1064
- const Fallback = ({ reason, copy, searchPath, contactPath }) => /* @__PURE__ */ jsxs("div", {
1065
- className: "fallback",
1066
- role: "status",
1067
- children: [/* @__PURE__ */ jsx("p", {
1068
- className: "message",
1069
- "data-role": "assistant",
1070
- children: reason === "busy" ? copy.busy : copy.unavailable
1071
- }), /* @__PURE__ */ jsxs("div", {
1072
- className: "fallback-links",
1073
- children: [/* @__PURE__ */ jsx("a", {
1074
- className: "button",
1075
- href: searchPath,
1076
- children: copy.searchLink
1077
- }), /* @__PURE__ */ jsx("a", {
1078
- className: "link",
1079
- href: contactPath,
1080
- children: copy.contactLink
1081
- })]
1082
- })]
1083
- });
1084
- /** A refusal that a second try can get past: the message goes back into the field to send again. */
1085
- const RETRYABLE = /* @__PURE__ */ new Set(["busy", "error"]);
1086
- function Panel(props) {
1087
- const { copy, endpoint, open, pending, onSent, onStarted, onClose } = props;
1088
- const stored = useMemo(() => loadConversation(endpoint), [endpoint]);
1089
- const [accepted, setAccepted] = useState(stored !== null);
1090
- const [messages, setMessages] = useState(stored?.messages ?? []);
1091
- const [draft, setDraft] = useState("");
1092
- const [streaming, setStreaming] = useState(false);
1093
- const [unavailable, setUnavailable] = useState(null);
1094
- const session = useRef(stored?.sessionId ?? null);
1095
- const abort = useRef(null);
1096
- const list = useRef(null);
1097
- const following = useRef(true);
1098
- const lastRole = useRef(stored?.messages.at(-1)?.role);
1099
- const start = useRef(null);
1100
- const composer = useRef(null);
1101
- const panel = useRef(null);
1102
- const lastActivity = useRef(stored?.savedAt ?? Date.now());
1103
- const hasMessages = messages.length > 0;
1104
- const last = messages.at(-1);
1105
- const waiting = streaming && !unavailable && (last?.role === "visitor" || !last?.parts.some((part) => part.kind === "text"));
1106
- const expired = () => Date.now() - lastActivity.current >= SESSION_TTL_S * 1e3;
1107
- useEffect(() => () => abort.current?.abort(), []);
1108
- useEffect(() => {
1109
- const role = messages.at(-1)?.role;
1110
- const replyStarts = role === "assistant" && lastRole.current === "visitor";
1111
- lastRole.current = role;
1112
- const element = list.current;
1113
- if (!open || !element) return;
1114
- const turnTop = ([...element.querySelectorAll(":scope > .turn")].at(-1)?.offsetTop ?? 0) - 12;
1115
- if (replyStarts) element.scrollTop = turnTop;
1116
- else if (role === "assistant" && following.current) element.scrollTop = Math.max(element.scrollTop, Math.min(element.scrollHeight - element.clientHeight, turnTop));
1117
- else if (role === "visitor" || following.current) element.scrollTop = element.scrollHeight;
1118
- }, [
1119
- messages,
1120
- open,
1121
- waiting,
1122
- unavailable
1123
- ]);
1124
- useEffect(() => {
1125
- if (!open) return;
1126
- const touch = window.matchMedia?.("(pointer: coarse)").matches;
1127
- if (accepted && hasMessages && touch) panel.current?.focus();
1128
- else (accepted ? composer : start).current?.focus();
1129
- }, [accepted, open]);
1130
- useEffect(() => {
1131
- onStarted(hasMessages);
1132
- if (messages.at(-1)?.unconfirmed) return;
1133
- const kept = messages.filter((message) => !message.unconfirmed);
1134
- if (kept.length > 0) saveConversation(endpoint, {
1135
- sessionId: session.current,
1136
- messages: kept
1137
- }, lastActivity.current);
1138
- }, [
1139
- endpoint,
1140
- hasMessages,
1141
- messages,
1142
- onStarted
1143
- ]);
1144
- /** Sends one visitor message, typed or tapped (QuickReplies); nothing while a reply streams. */
1145
- const submit = async (text) => {
1146
- const message = text.trim().slice(0, MAX_MESSAGE_CHARS);
1147
- if (!message || streaming) return;
1148
- const fresh = expired();
1149
- if (fresh) {
1150
- session.current = null;
1151
- clearConversation(endpoint);
1152
- }
1153
- setDraft("");
1154
- const restoreDraft = () => setDraft((current) => current || message);
1155
- setUnavailable(null);
1156
- setStreaming(true);
1157
- setMessages((current) => {
1158
- const before = fresh ? [] : current;
1159
- const last = before.at(-1);
1160
- return [...last?.role === "visitor" && last.unconfirmed && last.parts[0]?.kind === "text" && last.parts[0].text === message ? before.slice(0, -1) : before, {
1161
- role: "visitor",
1162
- parts: [{
1163
- kind: "text",
1164
- text: message
1165
- }],
1166
- unconfirmed: true
1167
- }];
1168
- });
1169
- const sentAt = Date.now();
1170
- const controller = new AbortController();
1171
- abort.current = controller;
1172
- try {
1173
- await sendMessage(endpoint, {
1174
- sessionId: session.current,
1175
- message,
1176
- locale: props.locale,
1177
- clientVersion: props.clientVersion
1178
- }, (chatEvent) => {
1179
- if (controller.signal.aborted) return;
1180
- if (chatEvent.type === "session") session.current = chatEvent.sessionId;
1181
- else if (chatEvent.type === "text") setMessages((current) => appendToReply(current, {
1182
- kind: "text",
1183
- text: chatEvent.delta
1184
- }));
1185
- else if (chatEvent.type === "ui") setMessages((current) => appendToReply(current, {
1186
- kind: "ui",
1187
- spec: chatEvent.spec
1188
- }));
1189
- else if (chatEvent.type === "unavailable") {
1190
- setUnavailable(chatEvent.reason);
1191
- if (RETRYABLE.has(chatEvent.reason)) restoreDraft();
1192
- } else if (chatEvent.type === "done") {
1193
- lastActivity.current = sentAt;
1194
- setMessages(confirmed);
1195
- }
1196
- }, controller.signal);
1197
- } catch {
1198
- if (!controller.signal.aborted) {
1199
- setUnavailable("error");
1200
- restoreDraft();
1201
- }
1202
- } finally {
1203
- if (abort.current === controller) setStreaming(false);
1204
- }
1205
- };
1206
- useEffect(() => {
1207
- if (!open || !accepted || pending === null) return;
1208
- onSent();
1209
- submit(pending);
1210
- }, [
1211
- open,
1212
- accepted,
1213
- pending,
1214
- onSent
1215
- ]);
1216
- /** A new conversation: the service starts a new session at the next message. */
1217
- const restart = () => {
1218
- abort.current?.abort();
1219
- abort.current = null;
1220
- setStreaming(false);
1221
- setMessages([]);
1222
- setUnavailable(null);
1223
- session.current = null;
1224
- clearConversation(endpoint);
1225
- composer.current?.focus();
1226
- };
1227
- useEffect(() => {
1228
- if (!open) return;
1229
- return lockPageScroll();
1230
- }, [open]);
1231
- useEffect(() => {
1232
- if (!open) return;
1233
- document.addEventListener("astro:before-swap", onClose);
1234
- return () => document.removeEventListener("astro:before-swap", onClose);
1235
- }, [open, onClose]);
1236
- useEffect(() => {
1237
- if (open && hasMessages && !streaming && expired()) restart();
1238
- }, [open]);
1239
- /** Tab stays in the panel while it is open: the page behind the scrim is out of reach. */
1240
- const trapTab = (event) => {
1241
- const element = panel.current;
1242
- if (event.key !== "Tab" || !element) return;
1243
- const focusable = [...element.querySelectorAll("a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled])")];
1244
- const active = element.getRootNode().activeElement;
1245
- const edge = event.shiftKey ? focusable[0] : focusable.at(-1);
1246
- const outside = !element.contains(active) || event.shiftKey && active === element;
1247
- if (focusable.length === 0 || active === edge || outside) {
1248
- event.preventDefault();
1249
- (event.shiftKey ? focusable.at(-1) : focusable[0])?.focus();
1250
- }
1251
- };
1252
- const send = (event) => {
1253
- event.preventDefault();
1254
- submit(draft);
1255
- };
1256
- return /* @__PURE__ */ jsxs(ChatContext.Provider, {
1257
- value: {
1258
- ...props,
1259
- send: (text) => void submit(text),
1260
- busy: streaming
1261
- },
1262
- children: [/* @__PURE__ */ jsx("div", {
1263
- className: "scrim",
1264
- hidden: !open,
1265
- "aria-hidden": "true",
1266
- onClick: onClose
1267
- }), /* @__PURE__ */ jsxs("section", {
1268
- className: "panel",
1269
- role: "dialog",
1270
- "aria-modal": "true",
1271
- "aria-label": copy.title,
1272
- ref: panel,
1273
- tabIndex: -1,
1274
- hidden: !open,
1275
- inert: !open,
1276
- onKeyDown: (event) => {
1277
- if (event.key === "Escape") onClose();
1278
- trapTab(event);
1279
- },
1280
- children: [
1281
- /* @__PURE__ */ jsxs("header", {
1282
- className: "header",
1283
- children: [
1284
- /* @__PURE__ */ jsx("h2", {
1285
- className: "title",
1286
- children: copy.title
1287
- }),
1288
- hasMessages && /* @__PURE__ */ jsx("button", {
1289
- type: "button",
1290
- className: "icon-button restart",
1291
- "aria-label": copy.newConversation,
1292
- title: copy.newConversation,
1293
- onClick: restart,
1294
- children: /* @__PURE__ */ jsx(Restart, {})
1295
- }),
1296
- /* @__PURE__ */ jsx("button", {
1297
- type: "button",
1298
- className: "icon-button close",
1299
- "aria-label": copy.close,
1300
- onClick: onClose,
1301
- children: /* @__PURE__ */ jsx(Cross, {})
1302
- })
1303
- ]
1304
- }),
1305
- /* @__PURE__ */ jsx("p", {
1306
- className: "notice",
1307
- children: copy.notice
1308
- }),
1309
- accepted ? /* @__PURE__ */ jsxs(Fragment, { children: [
1310
- /* @__PURE__ */ jsxs("div", {
1311
- className: "messages",
1312
- role: "log",
1313
- "aria-busy": streaming,
1314
- ref: list,
1315
- onScroll: (event) => {
1316
- const element = event.currentTarget;
1317
- following.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
1318
- },
1319
- children: [
1320
- /* @__PURE__ */ jsx("div", {
1321
- className: "message",
1322
- "data-role": "assistant",
1323
- children: copy.greeting
1324
- }),
1325
- messages.map((message, index) => /* @__PURE__ */ jsxs("div", {
1326
- className: message.role === "assistant" ? "ui" : "turn",
1327
- children: [/* @__PURE__ */ jsxs("span", {
1328
- className: "sr-only",
1329
- children: [message.role === "assistant" ? copy.title : copy.yourMessage, ":"]
1330
- }), message.parts.map((part, partIndex) => part.kind === "text" ? /* @__PURE__ */ jsx("div", {
1331
- className: "message",
1332
- "data-role": message.role,
1333
- children: message.role === "assistant" ? /* @__PURE__ */ jsx(RichText, { text: part.text }) : part.text
1334
- }, partIndex) : /* @__PURE__ */ jsx(SpecView, { spec: part.spec }, partIndex))]
1335
- }, index)),
1336
- unavailable && /* @__PURE__ */ jsx(Fallback, {
1337
- reason: unavailable,
1338
- copy,
1339
- searchPath: props.searchPath,
1340
- contactPath: props.contactPath
1341
- }),
1342
- waiting && /* @__PURE__ */ jsx(Thinking, { label: copy.thinking })
1343
- ]
1344
- }),
1345
- /* @__PURE__ */ jsx("p", {
1346
- className: "sr-only",
1347
- role: "status",
1348
- children: waiting ? copy.thinking : ""
1349
- }),
1350
- /* @__PURE__ */ jsxs("div", {
1351
- className: "dock",
1352
- children: [!hasMessages && copy.suggestions.length > 0 && /* @__PURE__ */ jsx("div", {
1353
- className: "suggestions",
1354
- children: copy.suggestions.map((suggestion) => /* @__PURE__ */ jsxs("button", {
1355
- type: "button",
1356
- className: "suggestion",
1357
- onClick: () => void submit(suggestion),
1358
- children: [/* @__PURE__ */ jsx(TopicIcon, { text: suggestion }), suggestion]
1359
- }, suggestion))
1360
- }), /* @__PURE__ */ jsxs("form", {
1361
- className: "composer",
1362
- onSubmit: send,
1363
- children: [/* @__PURE__ */ jsx("textarea", {
1364
- ref: composer,
1365
- rows: 1,
1366
- value: draft,
1367
- maxLength: MAX_MESSAGE_CHARS,
1368
- placeholder: copy.placeholder,
1369
- "aria-label": copy.placeholder,
1370
- onChange: (event) => setDraft(event.target.value),
1371
- onKeyDown: (event) => {
1372
- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
1373
- event.preventDefault();
1374
- event.currentTarget.form?.requestSubmit();
1375
- }
1376
- }), /* @__PURE__ */ jsx("button", {
1377
- type: "submit",
1378
- className: "send",
1379
- "aria-label": copy.send,
1380
- disabled: streaming || !draft.trim(),
1381
- children: /* @__PURE__ */ jsx(Arrow, {})
1382
- })]
1383
- })]
1384
- })
1385
- ] }) : /* @__PURE__ */ jsx(Consent, {
1386
- copy,
1387
- pending,
1388
- onAccept: () => setAccepted(true),
1389
- buttonRef: start
1390
- })
1391
- ]
1392
- })]
1393
- });
1394
- }
1395
- //#endregion
1396
- export { Panel as default };