@v-office/website-chat 0.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1161 @@
1
+ import { i as SESSION_TTL_S, r as MAX_MESSAGE_CHARS } from "./protocol-BuNqZAMt.js";
2
+ import { a as clearConversation, l as sendMessage, n as Cross, o as loadConversation, r as Restart, s as saveConversation, t as Arrow } from "./icons-B5PAY3aU.js";
3
+ import { c as statePath, r as chatCatalog } from "./catalog-RXKmEmMW.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
+ /** See {@link StayHref}. */
10
+ const stayHref = (page, stay, searchPath) => {
11
+ const path = page ?? searchPath;
12
+ if (!stay) return path;
13
+ const pairs = [
14
+ ["start", stay.start],
15
+ ["end", stay.end],
16
+ ...partyPairs(stay.party, "pets").map(([key, value]) => [key, value])
17
+ ];
18
+ const query = new URLSearchParams(pairs);
19
+ return `${path}${path.includes("?") ? "&" : "?"}${query}`;
20
+ };
21
+ //#endregion
22
+ //#region src/map.tsx
23
+ /** What the style's sources ask to be credited, with their links, as MapLibre's attribution control
24
+ * would show it (OpenStreetMap's to its copyright page). */
25
+ const Attribution = () => /* @__PURE__ */ jsxs("p", {
26
+ className: "map-attribution",
27
+ children: [
28
+ /* @__PURE__ */ jsx("a", {
29
+ href: "https://openfreemap.org",
30
+ target: "_blank",
31
+ rel: "noopener noreferrer",
32
+ children: "OpenFreeMap"
33
+ }),
34
+ " ",
35
+ /* @__PURE__ */ jsx("a", {
36
+ href: "https://www.openmaptiles.org/",
37
+ target: "_blank",
38
+ rel: "noopener noreferrer",
39
+ children: "© OpenMapTiles"
40
+ }),
41
+ " ",
42
+ "Data from",
43
+ " ",
44
+ /* @__PURE__ */ jsx("a", {
45
+ href: "https://www.openstreetmap.org/copyright",
46
+ target: "_blank",
47
+ rel: "noopener noreferrer",
48
+ children: "OpenStreetMap"
49
+ })
50
+ ]
51
+ });
52
+ const MapCard = ({ rental, label, notice, load, children }) => {
53
+ const [shown, setShown] = useState(false);
54
+ const box = useRef(null);
55
+ const location = rental.location;
56
+ useEffect(() => {
57
+ const element = box.current;
58
+ if (!shown || !element || !location) return;
59
+ let removed = false;
60
+ let map;
61
+ import("maplibre-gl").then((module) => {
62
+ if (removed) return;
63
+ const library = module;
64
+ const { Map, Marker } = library.default ?? library;
65
+ const center = [location.lng, location.lat];
66
+ const created = new Map({
67
+ container: element,
68
+ style: DEFAULT_MAP_STYLE_URL,
69
+ center,
70
+ zoom: 13,
71
+ attributionControl: false,
72
+ scrollZoom: false
73
+ });
74
+ const pin = document.createElement("span");
75
+ pin.className = "map-pin";
76
+ new Marker({ element: pin }).setLngLat(center).addTo(created);
77
+ map = created;
78
+ }).catch(() => {
79
+ if (!removed) setShown(false);
80
+ });
81
+ return () => {
82
+ removed = true;
83
+ map?.remove();
84
+ };
85
+ }, [shown, location]);
86
+ if (!location) return null;
87
+ return /* @__PURE__ */ jsxs("article", {
88
+ className: "card map-card",
89
+ children: [shown ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
90
+ ref: box,
91
+ className: "map",
92
+ role: "group",
93
+ "aria-label": label
94
+ }), /* @__PURE__ */ jsx(Attribution, {})] }) : /* @__PURE__ */ jsxs("div", {
95
+ className: "map-gate",
96
+ children: [/* @__PURE__ */ jsx("p", {
97
+ className: "muted",
98
+ children: notice
99
+ }), /* @__PURE__ */ jsx("button", {
100
+ type: "button",
101
+ className: "button",
102
+ onClick: () => setShown(true),
103
+ children: load
104
+ })]
105
+ }), children]
106
+ });
107
+ };
108
+ //#endregion
109
+ //#region src/registry.tsx
110
+ const ChatContext = createContext(null);
111
+ const useChat = () => {
112
+ const value = useContext(ChatContext);
113
+ if (!value) throw new Error("A chat component rendered outside the chat panel.");
114
+ return value;
115
+ };
116
+ /** A stay from the state; with a `rentalId`, only a stay of that rental, so a price never shows
117
+ * under another rental's name, whatever a spec pairs. */
118
+ const useStay = (stayId, rentalId) => {
119
+ const stay = useStateValue(statePath("stays", stayId ?? "")) ?? null;
120
+ return stay && (rentalId === void 0 || stay.rentalId === rentalId) ? stay : null;
121
+ };
122
+ const useRental = (rentalId) => useStateValue(statePath("rentals", rentalId)) ?? null;
123
+ const day = (value) => /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
124
+ /** "12.–19. Juli 2027" from two `YYYY-MM-DD` days. */
125
+ const period = (stay, locale) => new Intl.DateTimeFormat(locale, {
126
+ day: "numeric",
127
+ month: "short",
128
+ year: "numeric",
129
+ timeZone: "UTC"
130
+ }).formatRange(day(stay.start), day(stay.end));
131
+ /** "14.–21. Juli": the short form for choices next to each other. */
132
+ const shortPeriod = (stay, locale) => new Intl.DateTimeFormat(locale, {
133
+ day: "numeric",
134
+ month: "short",
135
+ timeZone: "UTC"
136
+ }).formatRange(day(stay.start), day(stay.end));
137
+ /** "1 Kind", "2 Kinder": a count in its plural form. */
138
+ const counted = (forms, n, locale) => (forms[new Intl.PluralRules(locale).select(n)] ?? forms.other).replace("{n}", String(n));
139
+ /** A review count as the PMS formats it ("36", or "1 Bewertungen" on v10), as the number it states. */
140
+ const reviewCount = (count) => Number(count.replace(/\D/g, ""));
141
+ const partyText = (stay, copy, locale) => {
142
+ const children = stay.party.children + stay.party.babies;
143
+ return [counted(copy.adults, stay.party.adults, locale), ...children ? [counted(copy.children, children, locale)] : []].join(", ");
144
+ };
145
+ const StayLine = ({ stay }) => {
146
+ const { copy, locale } = useChat();
147
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("span", {
148
+ className: "muted",
149
+ children: [
150
+ period(stay, locale),
151
+ " · ",
152
+ partyText(stay, copy, locale)
153
+ ]
154
+ }), !stay.bookable ? /* @__PURE__ */ jsx("span", {
155
+ className: "muted",
156
+ children: copy.notBookable
157
+ }) : stay.total && /* @__PURE__ */ jsxs("span", { children: [
158
+ copy.total,
159
+ ": ",
160
+ /* @__PURE__ */ jsx("span", {
161
+ className: "price",
162
+ children: stay.total
163
+ })
164
+ ] })] });
165
+ };
166
+ const useBookHref = (rentalId, stay) => {
167
+ const { searchPath } = useChat();
168
+ const page = useStateValue(statePath("pages", rentalId)) ?? null;
169
+ return stayHref(page, stay?.bookable ? stay : null, searchPath);
170
+ };
171
+ /** A link out of the chat. It waits while a reply streams: leaving the page then aborts the turn,
172
+ * which neither the service nor the widget keeps, and a first conversation would be lost. */
173
+ const ReplyLink = ({ href, className, children }) => {
174
+ const { busy } = useChat();
175
+ return busy ? /* @__PURE__ */ jsx("span", {
176
+ className,
177
+ "aria-disabled": "true",
178
+ children
179
+ }) : /* @__PURE__ */ jsx("a", {
180
+ className,
181
+ href,
182
+ children
183
+ });
184
+ };
185
+ const BookLink = ({ rentalId, stay }) => {
186
+ const { copy } = useChat();
187
+ return /* @__PURE__ */ jsx(ReplyLink, {
188
+ className: "button",
189
+ href: useBookHref(rentalId, stay),
190
+ children: stay?.bookable ? copy.bookStay : copy.viewRental
191
+ });
192
+ };
193
+ /** A row that snaps item by item, with arrow buttons and "n / total"; swipe, scroll or keys move it. */
194
+ const Snapper = ({ count, className, label, children }) => {
195
+ const { copy } = useChat();
196
+ const row = useRef(null);
197
+ const [index, setIndex] = useState(0);
198
+ const range = (element) => Math.max(1, element.scrollWidth - element.clientWidth);
199
+ const go = (step) => {
200
+ const element = row.current;
201
+ if (!element) return;
202
+ const still = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
203
+ const target = Math.min(count - 1, Math.max(0, index + step));
204
+ element.scrollTo({
205
+ left: target / (count - 1) * range(element),
206
+ behavior: still ? "auto" : "smooth"
207
+ });
208
+ };
209
+ return /* @__PURE__ */ jsxs("div", {
210
+ className: `${className}-wrap`,
211
+ role: "group",
212
+ "aria-roledescription": "carousel",
213
+ "aria-label": label,
214
+ children: [/* @__PURE__ */ jsx("div", {
215
+ ref: row,
216
+ className,
217
+ onScroll: (event) => {
218
+ const element = event.currentTarget;
219
+ setIndex(Math.round(element.scrollLeft / range(element) * (count - 1)));
220
+ },
221
+ children
222
+ }), count > 1 && /* @__PURE__ */ jsxs("div", {
223
+ className: "snap-nav",
224
+ children: [
225
+ /* @__PURE__ */ jsx("button", {
226
+ type: "button",
227
+ "aria-label": copy.previous,
228
+ disabled: index === 0,
229
+ onClick: () => go(-1),
230
+ children: "‹"
231
+ }),
232
+ /* @__PURE__ */ jsxs("span", {
233
+ className: "muted",
234
+ children: [
235
+ Math.min(index + 1, count),
236
+ " / ",
237
+ count
238
+ ]
239
+ }),
240
+ /* @__PURE__ */ jsx("button", {
241
+ type: "button",
242
+ "aria-label": copy.next,
243
+ disabled: index >= count - 1,
244
+ onClick: () => go(1),
245
+ children: "›"
246
+ })
247
+ ]
248
+ })]
249
+ });
250
+ };
251
+ const Gallery = ({ images, name }) => {
252
+ const { copy } = useChat();
253
+ if (images.length === 0) return null;
254
+ return /* @__PURE__ */ jsx(Snapper, {
255
+ count: images.length,
256
+ className: "gallery",
257
+ label: name,
258
+ children: images.map((image, index) => /* @__PURE__ */ jsx("img", {
259
+ src: image.src,
260
+ alt: image.alt || `${name}, ${copy.photo} ${index + 1}`,
261
+ loading: "lazy"
262
+ }, image.src))
263
+ });
264
+ };
265
+ const Rating = ({ rental }) => {
266
+ const { copy, locale } = useChat();
267
+ if (!rental.rating) return null;
268
+ const { value, count } = rental.rating;
269
+ return /* @__PURE__ */ jsxs("span", {
270
+ className: "muted",
271
+ children: [
272
+ /* @__PURE__ */ jsxs("span", {
273
+ className: "rating",
274
+ children: ["★ ", value]
275
+ }),
276
+ " (",
277
+ counted(copy.reviews, reviewCount(count), locale),
278
+ ")"
279
+ ]
280
+ });
281
+ };
282
+ /** The visitor's wishes the latest search that found the rental applied, which it therefore has. */
283
+ const useWishes = (rentalId) => useStateValue(statePath("wishes", rentalId)) ?? [];
284
+ const Wishes = ({ wishes }) => {
285
+ const { copy } = useChat();
286
+ if (wishes.length === 0) return null;
287
+ return /* @__PURE__ */ jsx("ul", {
288
+ className: "wishes",
289
+ "aria-label": copy.matchesWishes,
290
+ children: wishes.map((wish) => /* @__PURE__ */ jsx("li", { children: wish }, wish))
291
+ });
292
+ };
293
+ const Card = ({ rentalId, stayId }) => {
294
+ const { copy } = useChat();
295
+ const rental = useRental(rentalId);
296
+ const stay = useStay(stayId, rentalId);
297
+ const wishes = useWishes(rentalId);
298
+ if (!rental) return null;
299
+ return /* @__PURE__ */ jsxs("article", {
300
+ className: "card",
301
+ children: [/* @__PURE__ */ jsxs("div", {
302
+ className: "card-media",
303
+ children: [/* @__PURE__ */ jsx(Gallery, {
304
+ images: rental.images,
305
+ name: rental.name
306
+ }), wishes.length > 0 ? /* @__PURE__ */ jsx("span", {
307
+ className: "badge",
308
+ "aria-hidden": "true",
309
+ children: copy.matchesWishes
310
+ }) : null]
311
+ }), /* @__PURE__ */ jsxs("div", {
312
+ className: "card-body",
313
+ children: [
314
+ /* @__PURE__ */ jsx("h3", {
315
+ className: "card-title",
316
+ children: rental.name
317
+ }),
318
+ /* @__PURE__ */ jsx(Wishes, { wishes }),
319
+ rental.place && /* @__PURE__ */ jsx("span", {
320
+ className: "muted",
321
+ children: rental.place
322
+ }),
323
+ /* @__PURE__ */ jsx(Rating, { rental }),
324
+ rental.highlights.length > 0 && /* @__PURE__ */ jsx("span", {
325
+ className: "muted",
326
+ children: rental.highlights.slice(0, 3).join(" · ")
327
+ }),
328
+ stay && /* @__PURE__ */ jsx(StayLine, { stay }),
329
+ /* @__PURE__ */ jsx(BookLink, {
330
+ rentalId: rental.id,
331
+ stay
332
+ })
333
+ ]
334
+ })]
335
+ });
336
+ };
337
+ /** Monday-first weeks of one month, as `YYYY-MM-DD` days and `null` padding. */
338
+ const monthGrid = (month) => {
339
+ const first = day(`${month}-01`);
340
+ const days = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
341
+ const lead = (first.getUTCDay() + 6) % 7;
342
+ return [...Array.from({ length: lead }, () => null), ...Array.from({ length: days }, (_, i) => `${month}-${String(i + 1).padStart(2, "0")}`)];
343
+ };
344
+ const monthsOf = (calendar) => {
345
+ const months = [];
346
+ for (let month = calendar.from.slice(0, 7); month <= calendar.through.slice(0, 7);) {
347
+ months.push(month);
348
+ const next = day(`${month}-01`);
349
+ next.setUTCMonth(next.getUTCMonth() + 1);
350
+ month = next.toISOString().slice(0, 7);
351
+ }
352
+ return months;
353
+ };
354
+ const Calendar = ({ rentalId, stayId }) => {
355
+ const { copy, locale } = useChat();
356
+ const rental = useRental(rentalId);
357
+ const stay = useStay(stayId, rentalId);
358
+ const calendar = useStateValue(statePath("availability", rentalId)) ?? null;
359
+ const months = calendar ? monthsOf(calendar) : [];
360
+ const [shown, setShown] = useState(() => Math.max(0, stay ? months.indexOf(stay.start.slice(0, 7)) : 0));
361
+ if (!calendar || months.length === 0) return null;
362
+ const month = months[Math.min(shown, months.length - 1)];
363
+ const weekdays = Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(locale, {
364
+ weekday: "short",
365
+ timeZone: "UTC"
366
+ }).format(day(`2024-01-0${i + 1}`)));
367
+ const title = new Intl.DateTimeFormat(locale, {
368
+ month: "long",
369
+ year: "numeric",
370
+ timeZone: "UTC"
371
+ }).format(day(`${month}-01`));
372
+ const inStay = (date) => Boolean(stay && date >= stay.start && date <= stay.end);
373
+ const fullDate = new Intl.DateTimeFormat(locale, {
374
+ weekday: "long",
375
+ day: "numeric",
376
+ month: "long",
377
+ timeZone: "UTC"
378
+ });
379
+ const statusText = {
380
+ free: copy.free,
381
+ booked: copy.booked,
382
+ closed: copy.closed
383
+ };
384
+ return /* @__PURE__ */ jsxs("div", {
385
+ className: "calendar",
386
+ children: [
387
+ /* @__PURE__ */ jsxs("div", {
388
+ className: "calendar-head",
389
+ children: [
390
+ /* @__PURE__ */ jsx("button", {
391
+ type: "button",
392
+ "aria-label": copy.previous,
393
+ disabled: shown === 0,
394
+ onClick: () => setShown(shown - 1),
395
+ children: "‹"
396
+ }),
397
+ /* @__PURE__ */ jsxs("span", { children: [title, rental ? ` · ${rental.name}` : ""] }),
398
+ /* @__PURE__ */ jsx("button", {
399
+ type: "button",
400
+ "aria-label": copy.next,
401
+ disabled: shown >= months.length - 1,
402
+ onClick: () => setShown(shown + 1),
403
+ children: "›"
404
+ })
405
+ ]
406
+ }),
407
+ /* @__PURE__ */ jsxs("div", {
408
+ className: "calendar-grid",
409
+ children: [weekdays.map((weekday) => /* @__PURE__ */ jsx("span", {
410
+ className: "weekday",
411
+ children: weekday
412
+ }, weekday)), monthGrid(month).map((date, index) => {
413
+ if (!date) return /* @__PURE__ */ jsx("span", {}, `pad-${index}`);
414
+ const status = calendar.days[date] ?? "closed";
415
+ return /* @__PURE__ */ jsxs("span", {
416
+ className: "day",
417
+ "data-status": status,
418
+ "data-stay": inStay(date) || void 0,
419
+ children: [/* @__PURE__ */ jsx("span", {
420
+ "aria-hidden": "true",
421
+ children: Number(date.slice(8))
422
+ }), /* @__PURE__ */ jsx("span", {
423
+ className: "sr-only",
424
+ children: [
425
+ fullDate.format(day(date)),
426
+ statusText[status],
427
+ ...inStay(date) ? [copy.yourStay] : []
428
+ ].join(", ")
429
+ })]
430
+ }, date);
431
+ })]
432
+ }),
433
+ /* @__PURE__ */ jsxs("div", {
434
+ className: "legend muted",
435
+ children: [
436
+ /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-status": "free" }), copy.free] }),
437
+ /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-status": "booked" }), copy.booked] }),
438
+ stay && /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-stay": "true" }), copy.yourStay] })
439
+ ]
440
+ })
441
+ ]
442
+ });
443
+ };
444
+ const CompareColumn = ({ rentalId, stayId }) => {
445
+ const { copy, locale } = useChat();
446
+ const rental = useRental(rentalId);
447
+ const stay = useStay(stayId, rentalId);
448
+ const href = useBookHref(rentalId, stay);
449
+ if (!rental) return null;
450
+ return /* @__PURE__ */ jsxs("div", {
451
+ className: "compare-col",
452
+ children: [
453
+ /* @__PURE__ */ jsx("strong", { children: rental.name }),
454
+ /* @__PURE__ */ jsxs("dl", { children: [
455
+ /* @__PURE__ */ jsx("dt", { children: copy.compareRating }),
456
+ /* @__PURE__ */ jsx("dd", { children: rental.rating ? `★ ${rental.rating.value} (${counted(copy.reviews, reviewCount(rental.rating.count), locale)})` : "–" }),
457
+ /* @__PURE__ */ jsx("dt", { children: copy.compareRooms }),
458
+ /* @__PURE__ */ jsx("dd", { children: rental.rooms.slice(0, 3).join(", ") || "–" }),
459
+ /* @__PURE__ */ jsx("dt", { children: copy.compareHighlights }),
460
+ /* @__PURE__ */ jsx("dd", { children: rental.highlights.slice(0, 4).join(", ") || "–" }),
461
+ stay && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("dt", { children: copy.compareStay }), /* @__PURE__ */ jsxs("dd", { children: [
462
+ shortPeriod(stay, locale),
463
+ /* @__PURE__ */ jsx("br", {}),
464
+ !stay.bookable ? /* @__PURE__ */ jsx("span", {
465
+ className: "muted",
466
+ children: copy.notBookable
467
+ }) : stay.total && /* @__PURE__ */ jsx("span", {
468
+ className: "price",
469
+ children: stay.total
470
+ })
471
+ ] })] })
472
+ ] }),
473
+ /* @__PURE__ */ jsx(ReplyLink, {
474
+ className: "button",
475
+ href,
476
+ children: stay?.bookable ? copy.bookStay : copy.viewRental
477
+ })
478
+ ]
479
+ });
480
+ };
481
+ const AlternativeChip = ({ rentalId, stayId }) => {
482
+ const { locale } = useChat();
483
+ const stay = useStay(stayId, rentalId);
484
+ const href = useBookHref(rentalId, stay);
485
+ if (!stay?.bookable) return null;
486
+ return /* @__PURE__ */ jsxs(ReplyLink, {
487
+ className: "chip-link",
488
+ href,
489
+ children: [/* @__PURE__ */ jsx("strong", { children: shortPeriod(stay, locale) }), stay.total ? ` · ${stay.total}` : ""]
490
+ });
491
+ };
492
+ /** One arrival of a price calendar: its bar, the PMS's total, the day; tapping asks for that stay. */
493
+ const PriceColumn = ({ rentalId, option, asked, cheapest }) => {
494
+ const { copy, locale, send, busy } = useChat();
495
+ const rental = useRental(rentalId);
496
+ const stay = useStay(option.stayId, rentalId);
497
+ const free = Boolean(stay?.bookable && stay.total);
498
+ const arrival = new Intl.DateTimeFormat(locale, {
499
+ weekday: "short",
500
+ day: "numeric",
501
+ month: "numeric",
502
+ timeZone: "UTC"
503
+ }).format(day(option.start));
504
+ const dates = period(option, locale);
505
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
506
+ type: "button",
507
+ className: "price-column",
508
+ "data-asked": asked || void 0,
509
+ "data-cheapest": cheapest || void 0,
510
+ "data-free": free || void 0,
511
+ disabled: !free || !send || busy,
512
+ "aria-label": [
513
+ dates,
514
+ free ? stay.total : copy.closed,
515
+ ...cheapest ? [copy.cheapest] : [],
516
+ ...asked ? [copy.yourStay] : []
517
+ ].join(", "),
518
+ onClick: () => send?.(rental ? `${rental.name}: ${dates}` : dates),
519
+ children: [
520
+ /* @__PURE__ */ jsx("span", {
521
+ className: "bar-track",
522
+ "aria-hidden": "true",
523
+ children: free && /* @__PURE__ */ jsx("span", {
524
+ className: "bar",
525
+ style: { height: `${25 + 75 * (option.level ?? 1)}%` }
526
+ })
527
+ }),
528
+ /* @__PURE__ */ jsx("span", {
529
+ className: "price-text",
530
+ "aria-hidden": "true",
531
+ children: free ? stay.total : copy.closed
532
+ }),
533
+ /* @__PURE__ */ jsx("span", {
534
+ className: "muted",
535
+ "aria-hidden": "true",
536
+ children: arrival
537
+ })
538
+ ]
539
+ }) });
540
+ };
541
+ const PriceCalendarView = ({ rentalId }) => {
542
+ const { copy, locale } = useChat();
543
+ const rental = useRental(rentalId);
544
+ const calendar = useStateValue(statePath("priceCalendars", rentalId)) ?? null;
545
+ if (!calendar || calendar.options.length === 0) return null;
546
+ return /* @__PURE__ */ jsx("section", {
547
+ className: "card price-calendar",
548
+ "aria-label": copy.priceCalendar,
549
+ children: /* @__PURE__ */ jsxs("div", {
550
+ className: "card-body",
551
+ children: [
552
+ /* @__PURE__ */ jsx("h3", {
553
+ className: "card-title",
554
+ children: copy.priceCalendar
555
+ }),
556
+ /* @__PURE__ */ jsxs("span", {
557
+ className: "muted",
558
+ children: [counted(copy.priceCalendarNights, calendar.nights, locale), rental ? ` · ${rental.name}` : ""]
559
+ }),
560
+ /* @__PURE__ */ jsx("ul", {
561
+ className: "price-columns",
562
+ children: calendar.options.map((option) => /* @__PURE__ */ jsx(PriceColumn, {
563
+ rentalId,
564
+ option,
565
+ asked: option.start === calendar.asked,
566
+ cheapest: option.stayId !== null && option.stayId === calendar.cheapest
567
+ }, option.start))
568
+ }),
569
+ /* @__PURE__ */ jsxs("div", {
570
+ className: "legend muted",
571
+ 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] })]
572
+ })
573
+ ]
574
+ })
575
+ });
576
+ };
577
+ const ReviewsView = ({ rentalId }) => {
578
+ const { copy, locale } = useChat();
579
+ const rental = useRental(rentalId);
580
+ const reviews = useStateValue(statePath("reviews", rentalId)) ?? null;
581
+ if (!reviews) return null;
582
+ return /* @__PURE__ */ jsx("section", {
583
+ className: "card reviews",
584
+ "aria-label": copy.reviewsTitle,
585
+ children: /* @__PURE__ */ jsxs("div", {
586
+ className: "card-body",
587
+ children: [
588
+ /* @__PURE__ */ jsxs("h3", {
589
+ className: "card-title",
590
+ children: [copy.reviewsTitle, rental ? ` · ${rental.name}` : ""]
591
+ }),
592
+ /* @__PURE__ */ jsxs("div", {
593
+ className: "score",
594
+ children: [/* @__PURE__ */ jsx("span", {
595
+ className: "score-value",
596
+ children: reviews.rating
597
+ }), /* @__PURE__ */ jsxs("span", { children: [reviews.grade && /* @__PURE__ */ jsx("strong", { children: reviews.grade }), /* @__PURE__ */ jsxs("span", {
598
+ className: "muted",
599
+ children: [" ", counted(copy.reviews, reviewCount(reviews.count), locale)]
600
+ })] })]
601
+ }),
602
+ reviews.categories.length > 0 && /* @__PURE__ */ jsx("dl", {
603
+ className: "review-categories",
604
+ children: reviews.categories.map((category) => /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", { children: category.label }), /* @__PURE__ */ jsx("dd", { children: category.rating })] }, category.label))
605
+ }),
606
+ reviews.excerpts.map((excerpt) => /* @__PURE__ */ jsx("blockquote", {
607
+ className: "excerpt",
608
+ children: /* @__PURE__ */ jsx("p", { children: excerpt.text })
609
+ }, excerpt.text))
610
+ ]
611
+ })
612
+ });
613
+ };
614
+ const FactsView = ({ rentalId, groups }) => {
615
+ const rental = useRental(rentalId);
616
+ const known = useStateValue(statePath("facts", rentalId)) ?? [];
617
+ const shown = groups.flatMap((label) => {
618
+ const group = known.find((candidate) => candidate.label.toLowerCase() === label.toLowerCase());
619
+ return group ? [group] : [];
620
+ });
621
+ if (!rental || shown.length === 0) return null;
622
+ return /* @__PURE__ */ jsx("section", {
623
+ className: "card facts",
624
+ "aria-label": rental.name,
625
+ children: /* @__PURE__ */ jsxs("div", {
626
+ className: "card-body",
627
+ children: [/* @__PURE__ */ jsx("h3", {
628
+ className: "card-title",
629
+ children: rental.name
630
+ }), shown.map((group) => /* @__PURE__ */ jsxs("div", {
631
+ className: "fact-group",
632
+ children: [/* @__PURE__ */ jsx("h4", { children: group.label }), /* @__PURE__ */ jsx("ul", { children: group.items.map((item) => /* @__PURE__ */ jsx("li", { children: item }, item)) })]
633
+ }, group.label))]
634
+ })
635
+ });
636
+ };
637
+ const RentalMapView = ({ rentalId }) => {
638
+ const { copy } = useChat();
639
+ const rental = useRental(rentalId);
640
+ const href = useBookHref(rentalId, null);
641
+ if (!rental) return null;
642
+ return /* @__PURE__ */ jsx(MapCard, {
643
+ rental,
644
+ label: copy.mapLabel.replace("{name}", rental.name),
645
+ notice: copy.mapNotice,
646
+ load: copy.mapLoad,
647
+ children: /* @__PURE__ */ jsxs("div", {
648
+ className: "card-body",
649
+ children: [
650
+ /* @__PURE__ */ jsx("h3", {
651
+ className: "card-title",
652
+ children: rental.name
653
+ }),
654
+ rental.place && /* @__PURE__ */ jsx("span", {
655
+ className: "muted",
656
+ children: rental.place
657
+ }),
658
+ /* @__PURE__ */ jsx(ReplyLink, {
659
+ className: "button",
660
+ href,
661
+ children: copy.viewRental
662
+ })
663
+ ]
664
+ })
665
+ });
666
+ };
667
+ /** The registry the panel renders specs with. */
668
+ const { registry } = defineRegistry(chatCatalog, { components: {
669
+ Stack: ({ children }) => /* @__PURE__ */ jsx("div", {
670
+ className: "ui",
671
+ children
672
+ }),
673
+ RentalCard: ({ props }) => /* @__PURE__ */ jsx(Card, {
674
+ rentalId: props.rentalId,
675
+ stayId: props.stayId
676
+ }),
677
+ RentalCarousel: ({ props }) => {
678
+ const { copy } = useChat();
679
+ return /* @__PURE__ */ jsx(Snapper, {
680
+ count: props.items.length,
681
+ className: "carousel",
682
+ label: copy.title,
683
+ children: props.items.map((item) => /* @__PURE__ */ jsx(Card, {
684
+ rentalId: item.rentalId,
685
+ stayId: item.stayId
686
+ }, `${item.rentalId}:${item.stayId}`))
687
+ });
688
+ },
689
+ StayQuote: ({ props }) => {
690
+ const stay = useStay(props.stayId);
691
+ const rental = useRental(stay?.rentalId ?? "");
692
+ if (!stay) return null;
693
+ return /* @__PURE__ */ jsx("article", {
694
+ className: "card",
695
+ children: /* @__PURE__ */ jsxs("div", {
696
+ className: "card-body",
697
+ children: [
698
+ rental && /* @__PURE__ */ jsx("h3", {
699
+ className: "card-title",
700
+ children: rental.name
701
+ }),
702
+ /* @__PURE__ */ jsx(StayLine, { stay }),
703
+ /* @__PURE__ */ jsx(BookLink, {
704
+ rentalId: stay.rentalId,
705
+ stay
706
+ })
707
+ ]
708
+ })
709
+ });
710
+ },
711
+ PriceBreakdown: ({ props }) => {
712
+ const { copy } = useChat();
713
+ const stay = useStay(props.stayId);
714
+ if (!stay?.bookable || !stay.total) return null;
715
+ return /* @__PURE__ */ jsx("section", {
716
+ className: "card breakdown",
717
+ "aria-label": copy.priceBreakdown,
718
+ children: /* @__PURE__ */ jsxs("div", {
719
+ className: "card-body",
720
+ children: [
721
+ /* @__PURE__ */ jsx("span", {
722
+ className: "muted",
723
+ children: copy.priceBreakdown
724
+ }),
725
+ stay.lines.map((line) => /* @__PURE__ */ jsxs("div", {
726
+ className: "line",
727
+ children: [/* @__PURE__ */ jsx("span", { children: line.label }), /* @__PURE__ */ jsx("span", { children: line.amount })]
728
+ }, `${line.label}:${line.amount}`)),
729
+ /* @__PURE__ */ jsxs("div", {
730
+ className: "line total",
731
+ children: [/* @__PURE__ */ jsx("span", { children: copy.total }), /* @__PURE__ */ jsx("span", { children: stay.total })]
732
+ })
733
+ ]
734
+ })
735
+ });
736
+ },
737
+ AlternativeStays: ({ props }) => {
738
+ const { copy } = useChat();
739
+ return /* @__PURE__ */ jsx("div", {
740
+ className: "alternatives",
741
+ role: "group",
742
+ "aria-label": copy.otherDates,
743
+ children: props.stayIds.map((stayId) => /* @__PURE__ */ jsx(AlternativeChip, {
744
+ rentalId: props.rentalId,
745
+ stayId
746
+ }, stayId))
747
+ });
748
+ },
749
+ AvailabilityCalendar: ({ props }) => /* @__PURE__ */ jsx(Calendar, {
750
+ rentalId: props.rentalId,
751
+ stayId: props.stayId
752
+ }),
753
+ PriceCalendar: ({ props }) => /* @__PURE__ */ jsx(PriceCalendarView, { rentalId: props.rentalId }),
754
+ ReviewSummary: ({ props }) => /* @__PURE__ */ jsx(ReviewsView, { rentalId: props.rentalId }),
755
+ RentalFacts: ({ props }) => /* @__PURE__ */ jsx(FactsView, {
756
+ rentalId: props.rentalId,
757
+ groups: props.groups
758
+ }),
759
+ RentalMap: ({ props }) => /* @__PURE__ */ jsx(RentalMapView, { rentalId: props.rentalId }),
760
+ Compare: ({ props }) => /* @__PURE__ */ jsx("div", {
761
+ className: "compare",
762
+ children: props.items.map((item) => /* @__PURE__ */ jsx(CompareColumn, {
763
+ rentalId: item.rentalId,
764
+ stayId: item.stayId
765
+ }, `${item.rentalId}:${item.stayId}`))
766
+ }),
767
+ StayLink: ({ props }) => {
768
+ const stay = useStay(props.stayId, props.rentalId);
769
+ return /* @__PURE__ */ jsx(BookLink, {
770
+ rentalId: props.rentalId,
771
+ stay
772
+ });
773
+ },
774
+ ContactLink: () => {
775
+ const { copy, contactPath } = useChat();
776
+ return /* @__PURE__ */ jsx(ReplyLink, {
777
+ className: "link",
778
+ href: contactPath,
779
+ children: copy.contactLink
780
+ });
781
+ },
782
+ QuickReplies: ({ props }) => {
783
+ const { send, busy } = useChat();
784
+ if (!send) return null;
785
+ return /* @__PURE__ */ jsx("div", {
786
+ className: "quick-replies",
787
+ children: props.options.map((option) => /* @__PURE__ */ jsx("button", {
788
+ type: "button",
789
+ className: "chip",
790
+ disabled: busy,
791
+ onClick: () => send(option),
792
+ children: option
793
+ }, option))
794
+ });
795
+ }
796
+ } });
797
+ //#endregion
798
+ //#region src/panel.tsx
799
+ /** Appends one event's content to the reply being streamed (the last message). */
800
+ const appendToReply = (messages, part) => {
801
+ const last = messages.at(-1);
802
+ if (!last || last.role !== "assistant") return [...messages, {
803
+ role: "assistant",
804
+ parts: [part],
805
+ unconfirmed: true
806
+ }];
807
+ const tail = last.parts.at(-1);
808
+ const parts = part.kind === "text" && tail?.kind === "text" ? [...last.parts.slice(0, -1), {
809
+ kind: "text",
810
+ text: tail.text + part.text
811
+ }] : [...last.parts, part];
812
+ return [...messages.slice(0, -1), {
813
+ ...last,
814
+ parts
815
+ }];
816
+ };
817
+ /** The service finished the turn: its messages, from its visitor message on, are the
818
+ * conversation's now. An earlier turn it refused stays unconfirmed, on this page only. */
819
+ const confirmed = (messages) => {
820
+ const turn = messages.findLastIndex((message) => message.role === "visitor");
821
+ return messages.map((message, index) => index >= turn && message.unconfirmed ? {
822
+ role: message.role,
823
+ parts: message.parts
824
+ } : message);
825
+ };
826
+ const SCROLL_LOCK = "data-v-office-chat-open";
827
+ /** Locks the page's scroll until the returned function runs. The rule lives in the page's head (a
828
+ * shadow root's styles cannot reach <html>) and is added once. */
829
+ const lockPageScroll = () => {
830
+ const root = document.documentElement;
831
+ if (!document.getElementById(SCROLL_LOCK)) {
832
+ const style = document.createElement("style");
833
+ style.id = SCROLL_LOCK;
834
+ style.textContent = `html[${SCROLL_LOCK}] { overflow: hidden; }`;
835
+ document.head.append(style);
836
+ }
837
+ root.setAttribute(SCROLL_LOCK, "");
838
+ return () => root.removeAttribute(SCROLL_LOCK);
839
+ };
840
+ const SpecView = ({ spec }) => /* @__PURE__ */ jsx(JSONUIProvider, {
841
+ registry,
842
+ initialState: spec.state ?? {},
843
+ children: /* @__PURE__ */ jsx(Renderer, {
844
+ spec,
845
+ registry
846
+ })
847
+ });
848
+ const Consent = ({ copy, pending, onAccept, buttonRef }) => /* @__PURE__ */ jsxs("div", {
849
+ className: "consent",
850
+ children: [
851
+ pending && /* @__PURE__ */ jsxs("figure", {
852
+ className: "pending",
853
+ children: [/* @__PURE__ */ jsx("figcaption", {
854
+ className: "muted",
855
+ children: copy.yourMessage
856
+ }), /* @__PURE__ */ jsx("p", { children: pending })]
857
+ }),
858
+ /* @__PURE__ */ jsx("p", { children: copy.consent }),
859
+ /* @__PURE__ */ jsx("button", {
860
+ ref: buttonRef,
861
+ type: "button",
862
+ className: "button",
863
+ onClick: onAccept,
864
+ children: copy.consentAccept
865
+ })
866
+ ]
867
+ });
868
+ const Thinking = ({ label }) => /* @__PURE__ */ jsxs("div", {
869
+ className: "thinking",
870
+ role: "status",
871
+ children: [
872
+ /* @__PURE__ */ jsx("i", {}),
873
+ /* @__PURE__ */ jsx("i", {}),
874
+ /* @__PURE__ */ jsx("i", {}),
875
+ /* @__PURE__ */ jsx("span", {
876
+ className: "sr-only",
877
+ children: label
878
+ })
879
+ ]
880
+ });
881
+ function Panel(props) {
882
+ const { copy, endpoint, open, pending, onSent, onStarted, onClose } = props;
883
+ const stored = useMemo(() => loadConversation(endpoint), [endpoint]);
884
+ const [accepted, setAccepted] = useState(stored !== null);
885
+ const [messages, setMessages] = useState(stored?.messages ?? []);
886
+ const [draft, setDraft] = useState("");
887
+ const [streaming, setStreaming] = useState(false);
888
+ const [unavailable, setUnavailable] = useState(null);
889
+ const session = useRef(stored?.sessionId ?? null);
890
+ const abort = useRef(null);
891
+ const end = useRef(null);
892
+ const start = useRef(null);
893
+ const composer = useRef(null);
894
+ const panel = useRef(null);
895
+ const lastActivity = useRef(stored?.savedAt ?? Date.now());
896
+ const hasMessages = messages.length > 0;
897
+ const expired = () => Date.now() - lastActivity.current >= SESSION_TTL_S * 1e3;
898
+ useEffect(() => () => abort.current?.abort(), []);
899
+ useEffect(() => {
900
+ if (open) end.current?.scrollIntoView({ block: "end" });
901
+ }, [messages, open]);
902
+ useEffect(() => {
903
+ if (open) (accepted ? composer : start).current?.focus();
904
+ }, [accepted, open]);
905
+ useEffect(() => {
906
+ onStarted(hasMessages);
907
+ if (messages.at(-1)?.unconfirmed) return;
908
+ const kept = messages.filter((message) => !message.unconfirmed);
909
+ if (kept.length > 0) saveConversation(endpoint, {
910
+ sessionId: session.current,
911
+ messages: kept
912
+ }, lastActivity.current);
913
+ }, [
914
+ endpoint,
915
+ hasMessages,
916
+ messages,
917
+ onStarted
918
+ ]);
919
+ /** Sends one visitor message, typed or tapped (QuickReplies); nothing while a reply streams. */
920
+ const submit = async (text) => {
921
+ const message = text.trim().slice(0, MAX_MESSAGE_CHARS);
922
+ if (!message || streaming) return;
923
+ const fresh = expired();
924
+ if (fresh) {
925
+ session.current = null;
926
+ clearConversation(endpoint);
927
+ }
928
+ setDraft("");
929
+ setUnavailable(null);
930
+ setStreaming(true);
931
+ setMessages((current) => [...fresh ? [] : current, {
932
+ role: "visitor",
933
+ parts: [{
934
+ kind: "text",
935
+ text: message
936
+ }],
937
+ unconfirmed: true
938
+ }]);
939
+ const sentAt = Date.now();
940
+ const controller = new AbortController();
941
+ abort.current = controller;
942
+ try {
943
+ await sendMessage(endpoint, {
944
+ sessionId: session.current,
945
+ message,
946
+ locale: props.locale,
947
+ clientVersion: props.clientVersion
948
+ }, (chatEvent) => {
949
+ if (controller.signal.aborted) return;
950
+ if (chatEvent.type === "session") session.current = chatEvent.sessionId;
951
+ else if (chatEvent.type === "text") setMessages((current) => appendToReply(current, {
952
+ kind: "text",
953
+ text: chatEvent.delta
954
+ }));
955
+ else if (chatEvent.type === "ui") setMessages((current) => appendToReply(current, {
956
+ kind: "ui",
957
+ spec: chatEvent.spec
958
+ }));
959
+ else if (chatEvent.type === "unavailable") setUnavailable(chatEvent.reason);
960
+ else if (chatEvent.type === "done") {
961
+ lastActivity.current = sentAt;
962
+ setMessages(confirmed);
963
+ }
964
+ }, controller.signal);
965
+ } catch {
966
+ if (!controller.signal.aborted) setUnavailable("error");
967
+ } finally {
968
+ if (abort.current === controller) setStreaming(false);
969
+ }
970
+ };
971
+ useEffect(() => {
972
+ if (!open || !accepted || pending === null) return;
973
+ onSent();
974
+ submit(pending);
975
+ }, [
976
+ open,
977
+ accepted,
978
+ pending,
979
+ onSent
980
+ ]);
981
+ /** A new conversation: the service starts a new session at the next message. */
982
+ const restart = () => {
983
+ abort.current?.abort();
984
+ abort.current = null;
985
+ setStreaming(false);
986
+ setMessages([]);
987
+ setUnavailable(null);
988
+ session.current = null;
989
+ clearConversation(endpoint);
990
+ composer.current?.focus();
991
+ };
992
+ useEffect(() => {
993
+ if (!open) return;
994
+ return lockPageScroll();
995
+ }, [open]);
996
+ useEffect(() => {
997
+ if (!open) return;
998
+ document.addEventListener("astro:before-swap", onClose);
999
+ return () => document.removeEventListener("astro:before-swap", onClose);
1000
+ }, [open, onClose]);
1001
+ useEffect(() => {
1002
+ if (open && hasMessages && !streaming && expired()) restart();
1003
+ }, [open]);
1004
+ /** Tab stays in the panel while it is open: the page behind the scrim is out of reach. */
1005
+ const trapTab = (event) => {
1006
+ const element = panel.current;
1007
+ if (event.key !== "Tab" || !element) return;
1008
+ const focusable = [...element.querySelectorAll("a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled])")];
1009
+ const active = element.getRootNode().activeElement;
1010
+ const edge = event.shiftKey ? focusable[0] : focusable.at(-1);
1011
+ if (focusable.length === 0 || active === edge || !element.contains(active)) {
1012
+ event.preventDefault();
1013
+ (event.shiftKey ? focusable.at(-1) : focusable[0])?.focus();
1014
+ }
1015
+ };
1016
+ const send = (event) => {
1017
+ event.preventDefault();
1018
+ submit(draft);
1019
+ };
1020
+ const waiting = streaming && messages.at(-1)?.role === "visitor";
1021
+ return /* @__PURE__ */ jsxs(ChatContext.Provider, {
1022
+ value: {
1023
+ ...props,
1024
+ send: (text) => void submit(text),
1025
+ busy: streaming
1026
+ },
1027
+ children: [/* @__PURE__ */ jsx("div", {
1028
+ className: "scrim",
1029
+ hidden: !open,
1030
+ "aria-hidden": "true",
1031
+ onClick: onClose
1032
+ }), /* @__PURE__ */ jsxs("section", {
1033
+ className: "panel",
1034
+ role: "dialog",
1035
+ "aria-modal": "true",
1036
+ "aria-label": copy.title,
1037
+ ref: panel,
1038
+ hidden: !open,
1039
+ inert: !open,
1040
+ onKeyDown: (event) => {
1041
+ if (event.key === "Escape") onClose();
1042
+ trapTab(event);
1043
+ },
1044
+ children: [
1045
+ /* @__PURE__ */ jsxs("header", {
1046
+ className: "header",
1047
+ children: [
1048
+ /* @__PURE__ */ jsx("h2", {
1049
+ className: "title",
1050
+ children: copy.title
1051
+ }),
1052
+ hasMessages && /* @__PURE__ */ jsx("button", {
1053
+ type: "button",
1054
+ className: "icon-button restart",
1055
+ "aria-label": copy.newConversation,
1056
+ title: copy.newConversation,
1057
+ onClick: restart,
1058
+ children: /* @__PURE__ */ jsx(Restart, {})
1059
+ }),
1060
+ /* @__PURE__ */ jsx("button", {
1061
+ type: "button",
1062
+ className: "icon-button close",
1063
+ "aria-label": copy.close,
1064
+ onClick: onClose,
1065
+ children: /* @__PURE__ */ jsx(Cross, {})
1066
+ })
1067
+ ]
1068
+ }),
1069
+ /* @__PURE__ */ jsx("p", {
1070
+ className: "notice",
1071
+ children: copy.notice
1072
+ }),
1073
+ accepted ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
1074
+ className: "messages",
1075
+ "aria-live": "polite",
1076
+ children: [
1077
+ /* @__PURE__ */ jsx("div", {
1078
+ className: "message",
1079
+ "data-role": "assistant",
1080
+ children: copy.greeting
1081
+ }),
1082
+ messages.map((message, index) => /* @__PURE__ */ jsx("div", {
1083
+ className: message.role === "assistant" ? "ui" : "turn",
1084
+ children: message.parts.map((part, partIndex) => part.kind === "text" ? /* @__PURE__ */ jsx("div", {
1085
+ className: "message",
1086
+ "data-role": message.role,
1087
+ children: part.text
1088
+ }, partIndex) : /* @__PURE__ */ jsx(SpecView, { spec: part.spec }, partIndex))
1089
+ }, index)),
1090
+ waiting && /* @__PURE__ */ jsx(Thinking, { label: copy.thinking }),
1091
+ /* @__PURE__ */ jsx("div", { ref: end })
1092
+ ]
1093
+ }), /* @__PURE__ */ jsxs("div", {
1094
+ className: "dock",
1095
+ children: [
1096
+ unavailable && /* @__PURE__ */ jsxs("div", {
1097
+ className: "fallback",
1098
+ role: "status",
1099
+ children: [
1100
+ /* @__PURE__ */ jsx("p", {
1101
+ className: "muted",
1102
+ children: unavailable === "busy" ? copy.busy : copy.unavailable
1103
+ }),
1104
+ /* @__PURE__ */ jsx("a", {
1105
+ className: "link",
1106
+ href: props.searchPath,
1107
+ children: copy.searchLink
1108
+ }),
1109
+ /* @__PURE__ */ jsx("a", {
1110
+ className: "link",
1111
+ href: props.contactPath,
1112
+ children: copy.contactLink
1113
+ })
1114
+ ]
1115
+ }),
1116
+ !hasMessages && copy.suggestions.length > 0 && /* @__PURE__ */ jsx("div", {
1117
+ className: "suggestions",
1118
+ children: copy.suggestions.map((suggestion) => /* @__PURE__ */ jsx("button", {
1119
+ type: "button",
1120
+ className: "suggestion",
1121
+ onClick: () => void submit(suggestion),
1122
+ children: suggestion
1123
+ }, suggestion))
1124
+ }),
1125
+ /* @__PURE__ */ jsxs("form", {
1126
+ className: "composer",
1127
+ onSubmit: send,
1128
+ children: [/* @__PURE__ */ jsx("textarea", {
1129
+ ref: composer,
1130
+ rows: 1,
1131
+ value: draft,
1132
+ maxLength: MAX_MESSAGE_CHARS,
1133
+ placeholder: copy.placeholder,
1134
+ "aria-label": copy.placeholder,
1135
+ onChange: (event) => setDraft(event.target.value),
1136
+ onKeyDown: (event) => {
1137
+ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
1138
+ event.preventDefault();
1139
+ event.currentTarget.form?.requestSubmit();
1140
+ }
1141
+ }), /* @__PURE__ */ jsx("button", {
1142
+ type: "submit",
1143
+ className: "send",
1144
+ "aria-label": copy.send,
1145
+ disabled: streaming || !draft.trim(),
1146
+ children: /* @__PURE__ */ jsx(Arrow, {})
1147
+ })]
1148
+ })
1149
+ ]
1150
+ })] }) : /* @__PURE__ */ jsx(Consent, {
1151
+ copy,
1152
+ pending,
1153
+ onAccept: () => setAccepted(true),
1154
+ buttonRef: start
1155
+ })
1156
+ ]
1157
+ })]
1158
+ });
1159
+ }
1160
+ //#endregion
1161
+ export { Panel as default };