@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.
@@ -0,0 +1,2594 @@
1
+ import { i as SESSION_TTL_S, r as MAX_MESSAGE_CHARS } from "./protocol-BuNqZAMt.js";
2
+ import { a as CircleInfo, c as Restart, d as TopicIcon, f as clearConversation, g as sendMessage, i as CircleCheck, m as saveConversation, n as Check, o as Cross, p as loadConversation, r as Chevron, s as House, t as Arrow, u as Tag } from "./icons-B0_KM_uQ.js";
3
+ import { d as statePath, r as chatCatalog } from "./catalog-Buxzskkg.js";
4
+ import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
5
+ import { createPortal, flushSync } from "react-dom";
6
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
+ import { JSONUIProvider, Renderer, defineRegistry, useStateValue } from "@json-render/react";
8
+ import { DEFAULT_MAP_STYLE_URL, partyPairs } from "@v-office/website-headless";
9
+ //#region src/format.ts
10
+ /** A `YYYY-MM-DD` day as a UTC date: the stay's days, never shifted by the visitor's time zone. */
11
+ const day = (value) => /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
12
+ /** The locale dates are formatted in: British English ("13 May"), as the replies write dates and as
13
+ * European visitors read them, never the US order "5/13" that a bare "en" gives. */
14
+ const dateLocale = (locale) => locale === "en" ? "en-GB" : "de-DE";
15
+ /** "14.–21. Juli", "26. Dez.–2. Jan.": the short form, without years even over New Year. */
16
+ const shortPeriod = (stay, locale) => {
17
+ const short = new Intl.DateTimeFormat(dateLocale(locale), {
18
+ day: "numeric",
19
+ month: "short",
20
+ timeZone: "UTC"
21
+ });
22
+ 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))}`;
23
+ };
24
+ const nightsOf = (stay) => Math.round((day(stay.end).getTime() - day(stay.start).getTime()) / 864e5);
25
+ /** "1 Kind", "2 Kinder": a count in its plural form, its number and noun joined by a no-break space,
26
+ * so a narrow card never ends a line on the "2". */
27
+ const counted = (forms, n, locale) => (forms[new Intl.PluralRules(locale).select(n)] ?? forms.other).replace("{n} ", `${n} `).replace("{n}", String(n));
28
+ //#endregion
29
+ //#region src/links.ts
30
+ const REF = ["ref", "chat"];
31
+ /** The query goes before a `#fragment`, so the anchor still lands. */
32
+ const withQuery = (path, pairs) => {
33
+ const hash = path.indexOf("#");
34
+ const [base, fragment] = hash === -1 ? [path, ""] : [path.slice(0, hash), path.slice(hash)];
35
+ return `${base}${base.includes("?") ? "&" : "?"}${new URLSearchParams([...pairs, REF])}${fragment}`;
36
+ };
37
+ /** See {@link ChatHref}. */
38
+ const chatHref = (path) => withQuery(path, []);
39
+ /** The stay's dates, party and chosen rate, as the rental page and the checkout both read them. */
40
+ const stayPairs = (stay) => [
41
+ ["start", stay.start],
42
+ ["end", stay.end],
43
+ ...partyPairs(stay.party, "pets").map(([key, value]) => [key, value]),
44
+ ...stay.policyId ? [["policy", stay.policyId]] : []
45
+ ];
46
+ /** See {@link StayHref}. */
47
+ const stayHref = (page, stay, searchPath) => {
48
+ return withQuery(page ?? searchPath, stay ? stayPairs(stay) : []);
49
+ };
50
+ /** See {@link BookingHref}. */
51
+ const bookingHref = (bookingPage, stay) => withQuery(bookingPage, [...stayPairs(stay), ...(stay.services ?? []).filter((service) => service.count > 0).map((service) => ["service", `${service.id}:${service.count}`])]);
52
+ /** See {@link BookHref}. */
53
+ const bookHref = ({ page, booking }, stay, searchPath) => stay?.bookable && booking ? bookingHref(booking, stay) : stayHref(page, stay?.bookable ? stay : null, searchPath);
54
+ //#endregion
55
+ //#region src/map.tsx
56
+ /** MapLibre and its worker, loaded on the first tap. MapLibre finds its worker beside its own module
57
+ * by default; bundled, the module is a hashed chunk with nothing beside it, so `?worker&url` has the
58
+ * site's bundler emit the worker as one self-contained file (MapLibre's own Vite setup), and
59
+ * `setWorkerUrl` points MapLibre at it before the first map reads it. */
60
+ const loadMapLibre = () => Promise.all([import("maplibre-gl"), import("maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url")]).then(([library, worker]) => {
61
+ library.setWorkerUrl(worker.default);
62
+ return library;
63
+ });
64
+ /** What the style's sources ask to be credited, with their links, as MapLibre's attribution control
65
+ * would show it (OpenStreetMap's to its copyright page). */
66
+ const Attribution = () => /* @__PURE__ */ jsxs("p", {
67
+ className: "map-attribution",
68
+ children: [
69
+ /* @__PURE__ */ jsx("a", {
70
+ href: "https://openfreemap.org",
71
+ target: "_blank",
72
+ rel: "noopener noreferrer",
73
+ children: "OpenFreeMap"
74
+ }),
75
+ " ",
76
+ /* @__PURE__ */ jsx("a", {
77
+ href: "https://www.openmaptiles.org/",
78
+ target: "_blank",
79
+ rel: "noopener noreferrer",
80
+ children: "© OpenMapTiles"
81
+ }),
82
+ " ",
83
+ "Data from",
84
+ " ",
85
+ /* @__PURE__ */ jsx("a", {
86
+ href: "https://www.openstreetmap.org/copyright",
87
+ target: "_blank",
88
+ rel: "noopener noreferrer",
89
+ children: "OpenStreetMap"
90
+ })
91
+ ]
92
+ });
93
+ const MapCard = ({ rental, label, notice, load, children }) => {
94
+ const [shown, setShown] = useState(false);
95
+ const box = useRef(null);
96
+ const location = rental.location;
97
+ useEffect(() => {
98
+ const element = box.current;
99
+ if (!shown || !element || !location) return;
100
+ let removed = false;
101
+ let map;
102
+ loadMapLibre().then(({ Map, Marker }) => {
103
+ if (removed) return;
104
+ const center = [location.lng, location.lat];
105
+ const created = new Map({
106
+ container: element,
107
+ style: DEFAULT_MAP_STYLE_URL,
108
+ center,
109
+ zoom: 13,
110
+ attributionControl: false,
111
+ scrollZoom: false
112
+ });
113
+ const pin = document.createElement("span");
114
+ pin.className = "map-pin";
115
+ new Marker({ element: pin }).setLngLat(center).addTo(created);
116
+ map = created;
117
+ }).catch(() => {
118
+ if (!removed) setShown(false);
119
+ });
120
+ return () => {
121
+ removed = true;
122
+ map?.remove();
123
+ };
124
+ }, [shown, location]);
125
+ if (!location) return null;
126
+ return /* @__PURE__ */ jsxs("article", {
127
+ className: "card map-card",
128
+ children: [shown ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
129
+ ref: box,
130
+ className: "map",
131
+ role: "group",
132
+ "aria-label": label
133
+ }), /* @__PURE__ */ jsx(Attribution, {})] }) : /* @__PURE__ */ jsxs("div", {
134
+ className: "map-gate",
135
+ children: [/* @__PURE__ */ jsx("p", {
136
+ className: "muted",
137
+ children: notice
138
+ }), /* @__PURE__ */ jsx("button", {
139
+ type: "button",
140
+ className: "button",
141
+ onClick: () => setShown(true),
142
+ children: load
143
+ })]
144
+ }), children]
145
+ });
146
+ };
147
+ //#endregion
148
+ //#region src/sheet.tsx
149
+ const Sheet = ({ host, label, aside, closeLabel, onClose, opener, children }) => {
150
+ const sheet = useRef(null);
151
+ const close = useRef(null);
152
+ useEffect(() => {
153
+ close.current?.focus();
154
+ const back = opener.current;
155
+ return () => back?.focus();
156
+ }, [opener]);
157
+ const onKeyDown = (event) => {
158
+ if (event.key === "Escape") {
159
+ event.stopPropagation();
160
+ onClose();
161
+ return;
162
+ }
163
+ if (event.key !== "Tab") return;
164
+ event.stopPropagation();
165
+ const focusable = [...sheet.current?.querySelectorAll("a[href], button:not([disabled])") ?? []];
166
+ const active = (sheet.current?.getRootNode())?.activeElement;
167
+ if (active === (event.shiftKey ? focusable[0] : focusable.at(-1)) || !focusable.includes(active)) {
168
+ event.preventDefault();
169
+ (event.shiftKey ? focusable.at(-1) : focusable[0])?.focus();
170
+ }
171
+ };
172
+ return createPortal(/* @__PURE__ */ jsx("div", {
173
+ className: "sheet-scrim",
174
+ onClick: onClose,
175
+ children: /* @__PURE__ */ jsxs("div", {
176
+ ref: sheet,
177
+ className: "sheet",
178
+ role: "dialog",
179
+ "aria-modal": "true",
180
+ "aria-label": label,
181
+ tabIndex: -1,
182
+ onClick: (event) => event.stopPropagation(),
183
+ onKeyDown,
184
+ children: [/* @__PURE__ */ jsxs("div", {
185
+ className: "sheet-head",
186
+ children: [
187
+ /* @__PURE__ */ jsx("strong", {
188
+ className: "sheet-title",
189
+ children: label
190
+ }),
191
+ aside,
192
+ /* @__PURE__ */ jsx("button", {
193
+ ref: close,
194
+ type: "button",
195
+ className: "icon-button",
196
+ "aria-label": closeLabel,
197
+ onClick: onClose,
198
+ children: /* @__PURE__ */ jsx(Cross, {})
199
+ })
200
+ ]
201
+ }), children]
202
+ })
203
+ }), host);
204
+ };
205
+ //#endregion
206
+ //#region src/registry.tsx
207
+ const ChatContext = createContext(null);
208
+ const useChat = () => {
209
+ const value = useContext(ChatContext);
210
+ if (!value) throw new Error("A chat component rendered outside the chat panel.");
211
+ return value;
212
+ };
213
+ /** A stay from the state; with a `rentalId`, only a stay of that rental, so a price never shows
214
+ * under another rental's name, whatever a spec pairs. */
215
+ const useStay = (stayId, rentalId) => {
216
+ const stay = useStateValue(statePath("stays", stayId ?? "")) ?? null;
217
+ return stay && (rentalId === void 0 || stay.rentalId === rentalId) ? stay : null;
218
+ };
219
+ const useRental = (rentalId) => useStateValue(statePath("rentals", rentalId)) ?? null;
220
+ /** The stay the visitor chose in place for a rental in this reply, and how to choose one. Kept by the
221
+ * panel with the conversation; outside its list, by the component. */
222
+ const useSelection = (rentalId) => {
223
+ const { message, part, selected, select } = useChat();
224
+ const [own, setOwn] = useState(null);
225
+ if (message === void 0 || part === void 0 || !select) return [own, setOwn];
226
+ const choose = (stayId) => select({
227
+ message,
228
+ part,
229
+ rentalId
230
+ }, stayId);
231
+ return [selected?.[`${message}:${part}:${rentalId}`] ?? null, choose];
232
+ };
233
+ /** "12.–19. Juli 2027" from two `YYYY-MM-DD` days. */
234
+ const period = (stay, locale) => new Intl.DateTimeFormat(dateLocale(locale), {
235
+ day: "numeric",
236
+ month: "short",
237
+ year: "numeric",
238
+ timeZone: "UTC"
239
+ }).formatRange(day(stay.start), day(stay.end));
240
+ const withWeekday = (locale) => new Intl.DateTimeFormat(dateLocale(locale), {
241
+ weekday: "short",
242
+ day: "numeric",
243
+ month: "short",
244
+ timeZone: "UTC"
245
+ });
246
+ /** German writes its short weekday "Sa.," in a date: noise in the largest dates of a reply, and width a
247
+ * phone does not have. Only the weekday is followed by ".,". */
248
+ const plainWeekday = (text) => text.replaceAll(".,", "");
249
+ /** "Sa 6. – Sa 13. Feb.": a quote's dates with their weekdays, which the visitor checks against the
250
+ * calendar. */
251
+ const weekdayPeriod = (stay, locale) => plainWeekday(withWeekday(locale).formatRange(day(stay.start), day(stay.end)));
252
+ /** "Sa 6. Feb.". */
253
+ const weekdayDay = (value, locale) => plainWeekday(withWeekday(locale).format(day(value)));
254
+ /** "7. Jan. 2027": a day that may lie in another year than the stay. */
255
+ const fullDay = (value, locale) => new Intl.DateTimeFormat(dateLocale(locale), {
256
+ day: "numeric",
257
+ month: "short",
258
+ year: "numeric",
259
+ timeZone: "UTC"
260
+ }).format(day(value));
261
+ /** "2 Schlafzimmer" from the PMS, its number and noun joined by a no-break space as in `counted`. */
262
+ const keepCount = (fact) => fact.replace(/^(\d+) /, "$1\xA0");
263
+ /** A review count as the PMS formats it ("36", or "1 Bewertungen" on v10), as the number it states. */
264
+ const reviewCount = (count) => Number(count.replace(/\D/g, ""));
265
+ /** "2 Erwachsene, 2 Kinder"; `withAges` adds the children's ages ("2 Kinder (6, 9)") when every
266
+ * child's age is known (babies have none in the party). */
267
+ const partyText = (stay, copy, locale, withAges = false) => {
268
+ const { party } = stay;
269
+ const children = party.children + party.babies;
270
+ const ages = party.childrenAges.filter((age) => age !== null);
271
+ const known = withAges && party.babies === 0 && ages.length === party.children;
272
+ return [
273
+ counted(copy.adults, party.adults, locale),
274
+ ...children ? [`${counted(copy.children, children, locale)}${known ? ` (${ages.join(", ")})` : ""}`] : [],
275
+ ...party.pets ? [counted(copy.pets, party.pets, locale)] : []
276
+ ].join(", ");
277
+ };
278
+ /** A bookable stay goes to the rental's booking page with everything chosen, where the site has one;
279
+ * otherwise to the rental page (or the search) with the stay filled in. */
280
+ const useBookHref = (rentalId, stay) => {
281
+ const { searchPath } = useChat();
282
+ const page = useStateValue(statePath("pages", rentalId)) ?? null;
283
+ const booking = useStateValue(statePath("bookingPages", rentalId)) ?? null;
284
+ return bookHref({
285
+ page,
286
+ booking
287
+ }, stay, searchPath);
288
+ };
289
+ /** A link out of the chat. It waits while a reply streams: leaving the page then aborts the turn,
290
+ * which neither the service nor the widget keeps, and a first conversation would be lost. */
291
+ const ReplyLink = ({ href, className, children }) => {
292
+ const { busy } = useChat();
293
+ return busy ? /* @__PURE__ */ jsx("span", {
294
+ className,
295
+ "aria-disabled": "true",
296
+ children
297
+ }) : /* @__PURE__ */ jsx("a", {
298
+ className,
299
+ href,
300
+ children
301
+ });
302
+ };
303
+ /** What a booking link is for, for a screen reader: in a links list, eight "Buchen" say nothing. */
304
+ const LinkTarget = ({ rentalId, stay }) => {
305
+ const { locale } = useChat();
306
+ const rental = useRental(rentalId);
307
+ if (!rental) return null;
308
+ return /* @__PURE__ */ jsx("span", {
309
+ className: "sr-only",
310
+ children: `, ${rental.name}${stay?.bookable ? `, ${shortPeriod(stay, locale)}` : ""}`
311
+ });
312
+ };
313
+ const BookLink = ({ rentalId, stay }) => {
314
+ const { copy } = useChat();
315
+ return /* @__PURE__ */ jsxs(ReplyLink, {
316
+ className: "button",
317
+ href: useBookHref(rentalId, stay),
318
+ children: [stay?.bookable ? copy.bookStay : copy.viewRental, /* @__PURE__ */ jsx(LinkTarget, {
319
+ rentalId,
320
+ stay
321
+ })]
322
+ });
323
+ };
324
+ /** A row that snaps item by item, with arrow buttons and "n / total"; swipe, scroll or keys move it. */
325
+ const Snapper = ({ count, className, label, caption, children }) => {
326
+ const { copy } = useChat();
327
+ const row = useRef(null);
328
+ const [index, setIndex] = useState(0);
329
+ const [fade, setFade] = useState();
330
+ useEffect(() => {
331
+ if (row.current) setFade(hiddenEdges(row.current));
332
+ }, [count]);
333
+ const range = (element) => Math.max(1, element.scrollWidth - element.clientWidth);
334
+ const go = (step) => {
335
+ const element = row.current;
336
+ if (!element) return;
337
+ const still = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
338
+ const [first, second] = element.children;
339
+ const card = first && second ? second.offsetLeft - first.offsetLeft : element.clientWidth;
340
+ element.scrollBy({
341
+ left: step * card,
342
+ behavior: still ? "auto" : "smooth"
343
+ });
344
+ };
345
+ return /* @__PURE__ */ jsxs("div", {
346
+ className: `${className}-wrap`,
347
+ role: "group",
348
+ "aria-roledescription": "carousel",
349
+ "aria-label": label,
350
+ children: [/* @__PURE__ */ jsx("div", {
351
+ ref: row,
352
+ className,
353
+ "data-fade": fade,
354
+ onScroll: (event) => {
355
+ const element = event.currentTarget;
356
+ setIndex(Math.round(element.scrollLeft / range(element) * (count - 1)));
357
+ setFade(hiddenEdges(element));
358
+ },
359
+ children
360
+ }), (count > 1 || caption) && /* @__PURE__ */ jsxs("div", {
361
+ className: "snap-nav",
362
+ children: [
363
+ caption && /* @__PURE__ */ jsx("span", {
364
+ className: "snap-caption",
365
+ children: caption
366
+ }),
367
+ count > 1 && /* @__PURE__ */ jsx("span", {
368
+ className: "snap-dots",
369
+ "aria-hidden": "true",
370
+ children: Array.from({ length: count }, (_, dot) => /* @__PURE__ */ jsx("i", { "data-current": dot === Math.min(index, count - 1) || void 0 }, dot))
371
+ }),
372
+ count > 1 && /* @__PURE__ */ jsxs(Fragment, { children: [
373
+ /* @__PURE__ */ jsx("button", {
374
+ type: "button",
375
+ className: "snap-arrow",
376
+ "aria-label": copy.previous,
377
+ disabled: index === 0,
378
+ onClick: () => go(-1),
379
+ children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
380
+ }),
381
+ /* @__PURE__ */ jsxs("span", {
382
+ className: "snap-count muted",
383
+ children: [
384
+ Math.min(index + 1, count),
385
+ " / ",
386
+ count
387
+ ]
388
+ }),
389
+ /* @__PURE__ */ jsx("button", {
390
+ type: "button",
391
+ className: "snap-arrow",
392
+ "aria-label": copy.next,
393
+ disabled: index >= count - 1,
394
+ onClick: () => go(1),
395
+ children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
396
+ })
397
+ ] })
398
+ ]
399
+ })]
400
+ });
401
+ };
402
+ /** The panel's width on a wide screen (theme.ts, `--chat-width`'s default) and its list's, inside the
403
+ * list's padding: what a photo on its own is drawn at there. */
404
+ const PANEL_PX = 700;
405
+ const LIST_PX = 652;
406
+ /** The sizes a photo is drawn at, for the browser to pick from the PMS's `srcset`: a phone's card in a
407
+ * row is most of the screen's width, a wide panel's at most 320 px; a card on its own spans the list. */
408
+ const ROW_PHOTO_SIZES = "(max-width: 480px) 86vw, 320px";
409
+ const SINGLE_PHOTO_SIZES = `(min-width: 1024px) ${LIST_PX}px, 100vw`;
410
+ /** The sheet covers the panel. */
411
+ const SHEET_PHOTO_SIZES = `(min-width: 1024px) ${PANEL_PX}px, 100vw`;
412
+ /** A rental's photos, swiped or stepped through with the arrows a pointer brings up. With `onOpen`
413
+ * each photo is a button that opens the photos large, and a counter chip says how many there are
414
+ * (only the shown photo is a Tab stop); without, dots say which one shows. `children` sit on the
415
+ * photo's foot. */
416
+ const Gallery = ({ images, name, sizes, onOpen, openerRef, children }) => {
417
+ const { copy } = useChat();
418
+ const row = useRef(null);
419
+ const [index, setIndex] = useState(0);
420
+ if (images.length === 0) return null;
421
+ const go = (to) => {
422
+ const element = row.current;
423
+ if (!element) return;
424
+ const still = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
425
+ element.scrollTo({
426
+ left: to * element.clientWidth,
427
+ behavior: still ? "auto" : "smooth"
428
+ });
429
+ };
430
+ const many = images.length > 1;
431
+ const shown = Math.min(index, images.length - 1);
432
+ return /* @__PURE__ */ jsxs("div", {
433
+ className: "gallery-wrap",
434
+ role: "group",
435
+ "aria-roledescription": "carousel",
436
+ "aria-label": name,
437
+ children: [
438
+ /* @__PURE__ */ jsx("div", {
439
+ ref: row,
440
+ className: "gallery",
441
+ onScroll: (event) => {
442
+ const element = event.currentTarget;
443
+ setIndex(Math.round(element.scrollLeft / Math.max(1, element.clientWidth)));
444
+ },
445
+ children: images.map((image, imageIndex) => {
446
+ const photo = /* @__PURE__ */ jsx("img", {
447
+ src: image.src,
448
+ ...image.srcset && {
449
+ srcSet: image.srcset,
450
+ sizes
451
+ },
452
+ alt: image.alt || `${name}, ${copy.photo} ${imageIndex + 1}`,
453
+ loading: "lazy"
454
+ }, image.src);
455
+ return onOpen ? /* @__PURE__ */ jsx("button", {
456
+ ref: imageIndex === shown ? openerRef : void 0,
457
+ type: "button",
458
+ className: "photo-open",
459
+ "aria-label": `${copy.openPhotos.replace("{name}", name)} (${imageIndex + 1} / ${images.length})`,
460
+ tabIndex: imageIndex === shown ? 0 : -1,
461
+ onClick: () => onOpen(imageIndex),
462
+ children: photo
463
+ }, image.src) : photo;
464
+ })
465
+ }),
466
+ children,
467
+ onOpen && /* @__PURE__ */ jsxs("span", {
468
+ className: "photo-count",
469
+ "aria-hidden": "true",
470
+ children: [
471
+ shown + 1,
472
+ " / ",
473
+ images.length
474
+ ]
475
+ }),
476
+ many && /* @__PURE__ */ jsxs(Fragment, { children: [
477
+ /* @__PURE__ */ jsx("button", {
478
+ type: "button",
479
+ className: "gallery-arrow",
480
+ "data-side": "prev",
481
+ "aria-label": copy.previous,
482
+ disabled: index === 0,
483
+ onClick: () => go(index - 1),
484
+ children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
485
+ }),
486
+ /* @__PURE__ */ jsx("button", {
487
+ type: "button",
488
+ className: "gallery-arrow",
489
+ "data-side": "next",
490
+ "aria-label": copy.next,
491
+ disabled: index >= images.length - 1,
492
+ onClick: () => go(index + 1),
493
+ children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
494
+ }),
495
+ !onOpen && /* @__PURE__ */ jsx("span", {
496
+ className: "dots",
497
+ "aria-hidden": "true",
498
+ children: images.map((image, imageIndex) => /* @__PURE__ */ jsx("i", { "data-current": imageIndex === index || void 0 }, image.src))
499
+ }),
500
+ /* @__PURE__ */ jsx("span", {
501
+ className: "sr-only",
502
+ children: `${copy.photo} ${shown + 1} / ${images.length}`
503
+ })
504
+ ] })
505
+ ]
506
+ });
507
+ };
508
+ /** A rental's photos large in a sheet over the panel: uncropped, with the PMS's caption, and the
509
+ * card's stay with its total and button kept in the foot. */
510
+ const PhotoSheet = ({ rental, stay, start, host, opener, onClose }) => {
511
+ const { copy, locale } = useChat();
512
+ const href = useBookHref(rental.id, stay);
513
+ const row = useRef(null);
514
+ const [index, setIndex] = useState(start);
515
+ const { images } = rental;
516
+ useEffect(() => {
517
+ const element = row.current;
518
+ if (element) element.scrollLeft = start * element.clientWidth;
519
+ }, [start]);
520
+ const prev = useRef(null);
521
+ const next = useRef(null);
522
+ const go = (to) => {
523
+ flushSync(() => setIndex(to));
524
+ if (row.current) row.current.scrollLeft = to * row.current.clientWidth;
525
+ if (to === 0) next.current?.focus();
526
+ else if (to === images.length - 1) prev.current?.focus();
527
+ };
528
+ const caption = images[index]?.alt;
529
+ const priced = stay?.bookable && stay.total ? stay : null;
530
+ return /* @__PURE__ */ jsxs(Sheet, {
531
+ host,
532
+ label: rental.name,
533
+ aside: /* @__PURE__ */ jsxs("span", {
534
+ className: "sheet-count",
535
+ children: [
536
+ index + 1,
537
+ " / ",
538
+ images.length
539
+ ]
540
+ }),
541
+ closeLabel: copy.closePhotos,
542
+ opener,
543
+ onClose,
544
+ children: [
545
+ /* @__PURE__ */ jsxs("div", {
546
+ className: "sheet-photo",
547
+ children: [/* @__PURE__ */ jsx("div", {
548
+ ref: row,
549
+ className: "sheet-photos",
550
+ onScroll: (event) => {
551
+ const element = event.currentTarget;
552
+ setIndex(Math.round(element.scrollLeft / Math.max(1, element.clientWidth)));
553
+ },
554
+ children: images.map((image, imageIndex) => /* @__PURE__ */ jsx("img", {
555
+ src: image.src,
556
+ ...image.srcset && {
557
+ srcSet: image.srcset,
558
+ sizes: SHEET_PHOTO_SIZES
559
+ },
560
+ alt: image.alt || `${rental.name}, ${copy.photo} ${imageIndex + 1}`,
561
+ loading: Math.abs(imageIndex - start) > 1 ? "lazy" : "eager"
562
+ }, image.src))
563
+ }), images.length > 1 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
564
+ ref: prev,
565
+ type: "button",
566
+ className: "gallery-arrow",
567
+ "data-side": "prev",
568
+ "aria-label": copy.previous,
569
+ disabled: index === 0,
570
+ onClick: () => go(index - 1),
571
+ children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
572
+ }), /* @__PURE__ */ jsx("button", {
573
+ ref: next,
574
+ type: "button",
575
+ className: "gallery-arrow",
576
+ "data-side": "next",
577
+ "aria-label": copy.next,
578
+ disabled: index >= images.length - 1,
579
+ onClick: () => go(index + 1),
580
+ children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
581
+ })] })]
582
+ }),
583
+ caption && /* @__PURE__ */ jsx("p", {
584
+ className: "sheet-caption",
585
+ children: caption
586
+ }),
587
+ /* @__PURE__ */ jsxs("div", {
588
+ className: "sheet-foot",
589
+ children: [priced && /* @__PURE__ */ jsxs("div", {
590
+ className: "foot-price",
591
+ children: [/* @__PURE__ */ jsx("span", {
592
+ className: "price",
593
+ children: priced.total
594
+ }), /* @__PURE__ */ jsxs("span", {
595
+ className: "foot-meta",
596
+ children: [
597
+ copy.total,
598
+ " · ",
599
+ /* @__PURE__ */ jsx("span", {
600
+ className: "period",
601
+ children: shortPeriod(priced, locale)
602
+ })
603
+ ]
604
+ })]
605
+ }), /* @__PURE__ */ jsxs(ReplyLink, {
606
+ className: "button compact",
607
+ href,
608
+ children: [stay?.bookable ? copy.book : copy.view, /* @__PURE__ */ jsx(LinkTarget, {
609
+ rentalId: rental.id,
610
+ stay
611
+ })]
612
+ })]
613
+ })
614
+ ]
615
+ });
616
+ };
617
+ /** "★ 4,8 (23)": the score and how many reviews, read out in words; on a card's photo, or beside the
618
+ * place of a card without photos. */
619
+ const PhotoRating = ({ rental }) => {
620
+ const { copy, locale } = useChat();
621
+ if (!rental.rating) return null;
622
+ const { value, count } = rental.rating;
623
+ const reviews = counted(copy.reviews, reviewCount(count), locale);
624
+ return /* @__PURE__ */ jsxs("span", {
625
+ className: "photo-rating",
626
+ role: "img",
627
+ "aria-label": `${value}, ${reviews}`,
628
+ children: [
629
+ /* @__PURE__ */ jsx("span", {
630
+ className: "star",
631
+ children: "★"
632
+ }),
633
+ " ",
634
+ value.replace(/\/5$/, ""),
635
+ " ",
636
+ /* @__PURE__ */ jsxs("small", { children: [
637
+ "(",
638
+ reviewCount(count),
639
+ ")"
640
+ ] })
641
+ ]
642
+ });
643
+ };
644
+ /** The visitor's wishes the latest search that found the rental applied, which it therefore has. */
645
+ const useWishes = (rentalId) => useStateValue(statePath("wishes", rentalId)) ?? [];
646
+ const Wishes = ({ wishes }) => {
647
+ const { copy } = useChat();
648
+ if (wishes.length === 0) return null;
649
+ return /* @__PURE__ */ jsx("ul", {
650
+ className: "wishes",
651
+ "aria-label": copy.matchesWishes,
652
+ children: wishes.map((wish) => /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx(Check, {}), wish] }, wish))
653
+ });
654
+ };
655
+ /** "7 Nächte · 6.–13. Mai": a stay's length and dates, short. */
656
+ const stayMeta = (stay, copy, locale) => /* @__PURE__ */ jsxs(Fragment, { children: [
657
+ counted(copy.nights, nightsOf(stay), locale),
658
+ " ·",
659
+ " ",
660
+ /* @__PURE__ */ jsx("span", {
661
+ className: "period",
662
+ children: shortPeriod(stay, locale)
663
+ })
664
+ ] });
665
+ /** "Gesamtpreis für 7 Nächte": what a total covers. */
666
+ const PriceLabel = ({ stay }) => {
667
+ const { copy, locale } = useChat();
668
+ return /* @__PURE__ */ jsx("span", {
669
+ className: "price-label",
670
+ children: counted(copy.totalForNights, nightsOf(stay), locale)
671
+ });
672
+ };
673
+ /** A discount's name beside a card's total: the first the PMS names, else a generic word. */
674
+ const DealBadge = ({ stay }) => {
675
+ const { copy } = useChat();
676
+ if (!stay.discount) return null;
677
+ return /* @__PURE__ */ jsx("span", {
678
+ className: "deal compact",
679
+ children: stay.discount.labels[0] ?? copy.dealFallback
680
+ });
681
+ };
682
+ /** "Frühbucher · Sie sparen 112,30 €": the discounts the PMS applied and, when it is one amount, the
683
+ * saving. No struck-through price: the total is what the visitor pays. */
684
+ const DealLine = ({ stay }) => {
685
+ const { copy } = useChat();
686
+ if (!stay.discount) return null;
687
+ const { labels, saving } = stay.discount;
688
+ return /* @__PURE__ */ jsxs("p", {
689
+ className: "deal",
690
+ children: [/* @__PURE__ */ jsx(Tag, {}), /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("strong", { children: labels.length > 0 ? labels.join(" · ") : copy.dealFallback }), saving !== null && /* @__PURE__ */ jsxs(Fragment, { children: [" · ", /* @__PURE__ */ jsx("span", {
691
+ className: "saving",
692
+ children: copy.youSave.replace("{amount}", saving)
693
+ })] })] })]
694
+ });
695
+ };
696
+ /** The rate's own name and what cancelling costs, as the PMS's policy states them; nothing without
697
+ * either (a search result, v9). The model never writes these dates. */
698
+ const Terms = ({ stay }) => {
699
+ const { copy, locale } = useChat();
700
+ const { cancellation, rateName } = stay;
701
+ if (!cancellation && !rateName) return null;
702
+ const [before, after = ""] = copy.freeCancellation.split("{date}");
703
+ return /* @__PURE__ */ jsxs(Fragment, { children: [cancellation?.kind === "free" ? /* @__PURE__ */ jsx(CircleCheck, {}) : /* @__PURE__ */ jsx(CircleInfo, {}), /* @__PURE__ */ jsxs("span", { children: [
704
+ rateName && `${rateName}${cancellation ? " · " : ""}`,
705
+ cancellation?.kind === "free" && /* @__PURE__ */ jsxs(Fragment, { children: [
706
+ before,
707
+ /* @__PURE__ */ jsx("strong", { children: fullDay(cancellation.until, locale) }),
708
+ after
709
+ ] }),
710
+ cancellation?.kind === "none" && copy.nonRefundable
711
+ ] })] });
712
+ };
713
+ /** A quote's price lines and total behind "Preisdetails", closed: the total above is what counts. */
714
+ const PriceDetails = ({ stay }) => {
715
+ const { copy } = useChat();
716
+ if (stay.lines.length === 0 || !stay.total) return null;
717
+ return /* @__PURE__ */ jsxs("details", {
718
+ className: "price-details",
719
+ children: [
720
+ /* @__PURE__ */ jsxs("summary", { children: [copy.priceDetails, /* @__PURE__ */ jsx(Chevron, { side: "next" })] }),
721
+ stay.lines.map((line, index) => /* @__PURE__ */ jsxs("div", {
722
+ className: "line",
723
+ children: [/* @__PURE__ */ jsx("span", { children: line.label }), /* @__PURE__ */ jsx("span", { children: line.amount })]
724
+ }, index)),
725
+ /* @__PURE__ */ jsxs("div", {
726
+ className: "line total",
727
+ children: [/* @__PURE__ */ jsx("span", { children: copy.total }), /* @__PURE__ */ jsx("span", { children: stay.total })]
728
+ })
729
+ ]
730
+ });
731
+ };
732
+ /** A card's foot: the stay's total, its length and dates and the party it is for, and the way to
733
+ * book or to the rental. */
734
+ const CardFoot = ({ rentalId, stay }) => {
735
+ const { copy, locale } = useChat();
736
+ const href = useBookHref(rentalId, stay);
737
+ return /* @__PURE__ */ jsxs("div", {
738
+ className: "card-foot",
739
+ children: [stay && /* @__PURE__ */ jsx("div", {
740
+ className: "foot-price",
741
+ children: !stay.bookable ? /* @__PURE__ */ jsx("span", {
742
+ className: "foot-meta",
743
+ children: copy.notBookable
744
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [
745
+ stay.total && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(PriceLabel, { stay }), /* @__PURE__ */ jsxs("span", {
746
+ className: "price-line",
747
+ children: [/* @__PURE__ */ jsx("span", {
748
+ className: "price",
749
+ children: stay.total
750
+ }), /* @__PURE__ */ jsx(DealBadge, { stay })]
751
+ })] }),
752
+ /* @__PURE__ */ jsx("span", {
753
+ className: "foot-meta",
754
+ children: stay.total ? /* @__PURE__ */ jsx("span", {
755
+ className: "period",
756
+ children: shortPeriod(stay, locale)
757
+ }) : stayMeta(stay, copy, locale)
758
+ }),
759
+ /* @__PURE__ */ jsx("span", {
760
+ className: "foot-meta",
761
+ children: partyText(stay, copy, locale)
762
+ })
763
+ ] })
764
+ }), /* @__PURE__ */ jsxs(ReplyLink, {
765
+ className: "button compact",
766
+ href,
767
+ children: [stay?.bookable ? copy.book : copy.view, /* @__PURE__ */ jsx(LinkTarget, {
768
+ rentalId,
769
+ stay
770
+ })]
771
+ })]
772
+ });
773
+ };
774
+ /** A flexible search's other free periods of a card's rental, as pills that switch the card's stay in
775
+ * place: no message, no model turn; the bar follows. */
776
+ const Periods = ({ rentalId, ids, current, onChoose }) => {
777
+ const { copy, locale, busy } = useChat();
778
+ const stays = useStateValue("/stays") ?? {};
779
+ const options = ids.flatMap((id) => {
780
+ const stay = stays[id];
781
+ return stay?.rentalId === rentalId && stay.bookable ? [stay] : [];
782
+ });
783
+ if (options.length < 2) return null;
784
+ const month = options[0].start.slice(0, 7);
785
+ const oneMonth = options.every((stay) => stay.start.slice(0, 7) === month && stay.end.slice(0, 7) === month);
786
+ const caption = oneMonth ? copy.freePeriodsIn.replace("{month}", new Intl.DateTimeFormat(dateLocale(locale), {
787
+ month: "long",
788
+ timeZone: "UTC"
789
+ }).format(day(`${month}-01`))) : copy.freePeriods;
790
+ const days = new Intl.DateTimeFormat(dateLocale(locale), {
791
+ day: "numeric",
792
+ timeZone: "UTC"
793
+ });
794
+ return /* @__PURE__ */ jsxs("div", {
795
+ className: "periods",
796
+ role: "group",
797
+ "aria-label": caption,
798
+ children: [/* @__PURE__ */ jsx("p", {
799
+ className: "periods-caption",
800
+ "aria-hidden": "true",
801
+ children: caption
802
+ }), /* @__PURE__ */ jsx("div", {
803
+ className: "period-pills",
804
+ "data-one-month": oneMonth || void 0,
805
+ children: options.map((stay) => /* @__PURE__ */ jsx("button", {
806
+ type: "button",
807
+ className: "period-pill",
808
+ "aria-pressed": stay.id === current,
809
+ disabled: busy || !stay.total,
810
+ onClick: () => onChoose(stay.id),
811
+ children: oneMonth ? days.formatRange(day(stay.start), day(stay.end)) : shortPeriod(stay, locale)
812
+ }, stay.id))
813
+ })]
814
+ });
815
+ };
816
+ const Card = ({ rentalId, stayId, inRow = false }) => {
817
+ const rental = useRental(rentalId);
818
+ const periods = useStateValue(statePath("periods", rentalId)) ?? [];
819
+ const [chosen, choose] = useSelection(rentalId);
820
+ const pills = stayId && periods.includes(stayId) ? periods.slice(0, 4) : [];
821
+ const shown = chosen && pills.includes(chosen) ? chosen : stayId;
822
+ const stay = useStay(shown, rentalId);
823
+ const wishes = useWishes(rentalId);
824
+ const { overlay } = useChat();
825
+ const [sheet, setSheet] = useState(null);
826
+ const opener = useRef(null);
827
+ if (!overlay && sheet !== null) setSheet(null);
828
+ if (!rental) return null;
829
+ const photos = rental.images.length > 0;
830
+ return /* @__PURE__ */ jsxs("article", {
831
+ className: "card rental-card",
832
+ "data-stay-id": stay?.id,
833
+ children: [
834
+ photos ? /* @__PURE__ */ jsx(Gallery, {
835
+ images: rental.images,
836
+ name: rental.name,
837
+ sizes: inRow ? ROW_PHOTO_SIZES : SINGLE_PHOTO_SIZES,
838
+ openerRef: opener,
839
+ ...overlay && { onOpen: setSheet },
840
+ children: (rental.place || rental.rating) && /* @__PURE__ */ jsxs("div", {
841
+ className: "photo-meta",
842
+ children: [/* @__PURE__ */ jsx("span", { children: rental.place }), /* @__PURE__ */ jsx(PhotoRating, { rental })]
843
+ })
844
+ }) : /* @__PURE__ */ jsx("div", {
845
+ className: "no-photo",
846
+ "aria-hidden": "true",
847
+ children: /* @__PURE__ */ jsx(House, {})
848
+ }),
849
+ overlay && sheet !== null && /* @__PURE__ */ jsx(PhotoSheet, {
850
+ rental,
851
+ stay,
852
+ start: sheet,
853
+ host: overlay,
854
+ opener,
855
+ onClose: () => setSheet(null)
856
+ }),
857
+ /* @__PURE__ */ jsxs("div", {
858
+ className: "card-body",
859
+ children: [
860
+ !photos && /* @__PURE__ */ jsxs("div", {
861
+ className: "card-meta",
862
+ children: [rental.place && /* @__PURE__ */ jsx("span", {
863
+ className: "place",
864
+ children: rental.place
865
+ }), /* @__PURE__ */ jsx(PhotoRating, { rental })]
866
+ }),
867
+ /* @__PURE__ */ jsx("h3", {
868
+ className: "card-title",
869
+ children: rental.name
870
+ }),
871
+ rental.highlights.length > 0 && /* @__PURE__ */ jsx("p", {
872
+ className: "specs",
873
+ children: rental.highlights.slice(0, 3).map(keepCount).join(" · ")
874
+ }),
875
+ /* @__PURE__ */ jsx(Wishes, { wishes })
876
+ ]
877
+ }),
878
+ /* @__PURE__ */ jsx(Periods, {
879
+ rentalId: rental.id,
880
+ ids: pills,
881
+ current: shown,
882
+ onChoose: choose
883
+ }),
884
+ /* @__PURE__ */ jsx(CardFoot, {
885
+ rentalId: rental.id,
886
+ stay
887
+ })
888
+ ]
889
+ });
890
+ };
891
+ /** A quote's head: a small photo, the place and the rental's name. */
892
+ const QuoteHead = ({ rental }) => {
893
+ if (!rental) return null;
894
+ const photo = rental.images[0];
895
+ return /* @__PURE__ */ jsxs("div", {
896
+ className: "quote-head",
897
+ children: [photo && /* @__PURE__ */ jsx("img", {
898
+ className: "thumb",
899
+ src: photo.src,
900
+ srcSet: photo.srcset,
901
+ sizes: "52px",
902
+ alt: ""
903
+ }), /* @__PURE__ */ jsxs("div", { children: [rental.place && /* @__PURE__ */ jsx("span", {
904
+ className: "place",
905
+ children: rental.place
906
+ }), /* @__PURE__ */ jsx("h3", {
907
+ className: "card-title",
908
+ children: rental.name
909
+ })] })]
910
+ });
911
+ };
912
+ /** The hand-over (`handover`), when the visitor has decided: what was chosen, the total, the one
913
+ * full-width button, and, where the button opens the rental's booking page, that nothing is booked
914
+ * until then and what that page asks. */
915
+ const HandOver = ({ rental, stay }) => {
916
+ const { copy, locale } = useChat();
917
+ const href = useBookHref(stay.rentalId, stay);
918
+ const booking = useStateValue(statePath("bookingPages", stay.rentalId)) ?? null;
919
+ const extras = (stay.services ?? []).filter((service) => service.label);
920
+ return /* @__PURE__ */ jsx("article", {
921
+ className: "card stay-card handover",
922
+ "data-stay-id": stay.id,
923
+ children: /* @__PURE__ */ jsxs("div", {
924
+ className: "card-body",
925
+ children: [
926
+ /* @__PURE__ */ jsx(QuoteHead, { rental }),
927
+ /* @__PURE__ */ jsxs("div", {
928
+ className: "ho-dates",
929
+ children: [
930
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("span", {
931
+ className: "price-label",
932
+ children: copy.arrival
933
+ }), /* @__PURE__ */ jsx("strong", { children: weekdayDay(stay.start, locale) })] }),
934
+ /* @__PURE__ */ jsx("span", {
935
+ className: "ho-nights",
936
+ children: counted(copy.nights, nightsOf(stay), locale)
937
+ }),
938
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("span", {
939
+ className: "price-label",
940
+ children: copy.departure
941
+ }), /* @__PURE__ */ jsx("strong", { children: weekdayDay(stay.end, locale) })] })
942
+ ]
943
+ }),
944
+ /* @__PURE__ */ jsxs("ul", {
945
+ className: "ho-lines",
946
+ children: [
947
+ /* @__PURE__ */ jsx("li", { children: partyText(stay, copy, locale, true) }),
948
+ (stay.cancellation || stay.rateName) && /* @__PURE__ */ jsx("li", {
949
+ className: "cancel-line",
950
+ children: /* @__PURE__ */ jsx(Terms, { stay })
951
+ }),
952
+ extras.map((extra) => /* @__PURE__ */ jsxs("li", {
953
+ className: "tick",
954
+ children: [/* @__PURE__ */ jsx(Check, {}), extra.count > 1 ? `${extra.count} × ${extra.label}` : extra.label]
955
+ }, extra.id))
956
+ ]
957
+ }),
958
+ /* @__PURE__ */ jsxs("div", {
959
+ className: "ho-total",
960
+ children: [/* @__PURE__ */ jsx(PriceLabel, { stay }), /* @__PURE__ */ jsx("span", {
961
+ className: "price",
962
+ children: stay.total
963
+ })]
964
+ }),
965
+ /* @__PURE__ */ jsxs(ReplyLink, {
966
+ className: "button block",
967
+ href,
968
+ children: [
969
+ copy.continueToBooking,
970
+ /* @__PURE__ */ jsx(Arrow, {}),
971
+ /* @__PURE__ */ jsx(LinkTarget, {
972
+ rentalId: stay.rentalId,
973
+ stay
974
+ })
975
+ ]
976
+ }),
977
+ booking && /* @__PURE__ */ jsx("p", {
978
+ className: "ho-note",
979
+ children: copy.nothingBookedYet
980
+ }),
981
+ booking && copy.handoverSteps.length > 0 && /* @__PURE__ */ jsx("ol", {
982
+ className: "ho-steps",
983
+ children: copy.handoverSteps.map((step) => /* @__PURE__ */ jsx("li", { children: step }, step))
984
+ }),
985
+ /* @__PURE__ */ jsx(PriceDetails, { stay })
986
+ ]
987
+ })
988
+ });
989
+ };
990
+ /** A stay the booking system refused, with the stay lengths it allows from the same arrival: each row
991
+ * asks for that stay's price, as the visitor's message. */
992
+ const Refused = ({ rental, stay, departures }) => {
993
+ const { copy, locale, send, busy, suggest } = useChat();
994
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("article", {
995
+ className: "card stay-card refused",
996
+ "data-stay-id": stay.id,
997
+ children: [/* @__PURE__ */ jsxs("div", {
998
+ className: "card-body",
999
+ children: [/* @__PURE__ */ jsx(QuoteHead, { rental }), /* @__PURE__ */ jsx("p", {
1000
+ className: "quote-when",
1001
+ children: copy.possibleFrom.replace("{date}", weekdayDay(stay.start, locale))
1002
+ })]
1003
+ }), /* @__PURE__ */ jsx("div", {
1004
+ className: "rows",
1005
+ children: departures.map((end) => {
1006
+ const range = period({
1007
+ start: stay.start,
1008
+ end
1009
+ }, locale);
1010
+ return /* @__PURE__ */ jsxs("button", {
1011
+ type: "button",
1012
+ className: "row",
1013
+ disabled: !send || busy,
1014
+ onClick: () => send?.(rental ? `${rental.name}: ${range}` : range),
1015
+ children: [/* @__PURE__ */ jsxs("span", {
1016
+ className: "row-when",
1017
+ children: [/* @__PURE__ */ jsx("strong", { children: counted(copy.nights, nightsOf({
1018
+ start: stay.start,
1019
+ end
1020
+ }), locale) }), /* @__PURE__ */ jsx("span", { children: copy.until.replace("{date}", weekdayDay(end, locale)) })]
1021
+ }), /* @__PURE__ */ jsxs("span", {
1022
+ className: "row-go",
1023
+ children: [copy.showPrice, /* @__PURE__ */ jsx(Chevron, { side: "next" })]
1024
+ })]
1025
+ }, end);
1026
+ })
1027
+ })]
1028
+ }), suggest && /* @__PURE__ */ jsx("button", {
1029
+ type: "button",
1030
+ className: "chip",
1031
+ disabled: busy,
1032
+ onClick: () => suggest(copy.otherArrivalStarter),
1033
+ children: copy.otherArrival
1034
+ })] });
1035
+ };
1036
+ /** One stay: its dates with weekdays and party, the labelled total with the button, the discount and
1037
+ * the cancellation terms, the price lines folded away. An unbookable stay says only that, as a card
1038
+ * does; with `handover`, a bookable priced stay becomes the hand-over. */
1039
+ const Quote = ({ stayId, handover }) => {
1040
+ const { copy, locale } = useChat();
1041
+ const stay = useStay(stayId);
1042
+ const rental = useRental(stay?.rentalId ?? "");
1043
+ const href = useBookHref(stay?.rentalId ?? "", stay);
1044
+ if (!stay) return null;
1045
+ if (!stay.bookable && stay.departures?.length) return /* @__PURE__ */ jsx(Refused, {
1046
+ rental,
1047
+ stay,
1048
+ departures: stay.departures
1049
+ });
1050
+ if (!stay.bookable) return /* @__PURE__ */ jsxs("article", {
1051
+ className: "card stay-card",
1052
+ "data-stay-id": stay.id,
1053
+ children: [/* @__PURE__ */ jsx("div", {
1054
+ className: "card-body",
1055
+ children: /* @__PURE__ */ jsx(QuoteHead, { rental })
1056
+ }), /* @__PURE__ */ jsx(CardFoot, {
1057
+ rentalId: stay.rentalId,
1058
+ stay
1059
+ })]
1060
+ });
1061
+ if (handover && stay.total) return /* @__PURE__ */ jsx(HandOver, {
1062
+ rental,
1063
+ stay
1064
+ });
1065
+ return /* @__PURE__ */ jsx("article", {
1066
+ className: "card stay-card",
1067
+ "data-stay-id": stay.id,
1068
+ children: /* @__PURE__ */ jsxs("div", {
1069
+ className: "card-body",
1070
+ children: [
1071
+ /* @__PURE__ */ jsx(QuoteHead, { rental }),
1072
+ /* @__PURE__ */ jsxs("p", {
1073
+ className: "quote-when",
1074
+ children: [
1075
+ /* @__PURE__ */ jsx("span", {
1076
+ className: "period",
1077
+ children: weekdayPeriod(stay, locale)
1078
+ }),
1079
+ " ·",
1080
+ " ",
1081
+ partyText(stay, copy, locale)
1082
+ ]
1083
+ }),
1084
+ /* @__PURE__ */ jsxs("div", {
1085
+ className: "price-row",
1086
+ children: [/* @__PURE__ */ jsx("div", {
1087
+ className: "foot-price",
1088
+ children: stay.total && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(PriceLabel, { stay }), /* @__PURE__ */ jsx("span", {
1089
+ className: "price",
1090
+ children: stay.total
1091
+ })] })
1092
+ }), /* @__PURE__ */ jsxs(ReplyLink, {
1093
+ className: "button compact",
1094
+ href,
1095
+ children: [copy.book, /* @__PURE__ */ jsx(LinkTarget, {
1096
+ rentalId: stay.rentalId,
1097
+ stay
1098
+ })]
1099
+ })]
1100
+ }),
1101
+ (stay.discount || stay.cancellation || stay.rateName) && /* @__PURE__ */ jsxs("div", {
1102
+ className: "quote-facts",
1103
+ children: [/* @__PURE__ */ jsx(DealLine, { stay }), (stay.cancellation || stay.rateName) && /* @__PURE__ */ jsx("p", {
1104
+ className: "cancel-line",
1105
+ children: /* @__PURE__ */ jsx(Terms, { stay })
1106
+ })]
1107
+ }),
1108
+ /* @__PURE__ */ jsx(PriceDetails, { stay })
1109
+ ]
1110
+ })
1111
+ });
1112
+ };
1113
+ /** Monday-first weeks of one month, as `YYYY-MM-DD` days and `null` padding. */
1114
+ const monthGrid = (month) => {
1115
+ const first = day(`${month}-01`);
1116
+ const days = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
1117
+ const lead = (first.getUTCDay() + 6) % 7;
1118
+ return [...Array.from({ length: lead }, () => null), ...Array.from({ length: days }, (_, i) => `${month}-${String(i + 1).padStart(2, "0")}`)];
1119
+ };
1120
+ const monthsOf = (calendar) => {
1121
+ const months = [];
1122
+ for (let month = calendar.from.slice(0, 7); month <= calendar.through.slice(0, 7);) {
1123
+ months.push(month);
1124
+ const next = day(`${month}-01`);
1125
+ next.setUTCMonth(next.getUTCMonth() + 1);
1126
+ month = next.toISOString().slice(0, 7);
1127
+ }
1128
+ return months;
1129
+ };
1130
+ /** A calendar day's price level as one to three ticks under its numeral: never an amount. */
1131
+ const Ticks = ({ level }) => /* @__PURE__ */ jsx("span", {
1132
+ className: "ticks",
1133
+ "aria-hidden": "true",
1134
+ children: Array.from({ length: level }, (_, tick) => /* @__PURE__ */ jsx("i", {}, tick))
1135
+ });
1136
+ /** A rental's calendar: taken days hatched, arrival days dotted, free days' price levels as ticks.
1137
+ * Where the chat can send, an arrival is a button; a tapped one asks for its price in the foot. */
1138
+ const Calendar = ({ rentalId, stayId }) => {
1139
+ const { copy, locale, send, busy } = useChat();
1140
+ const rental = useRental(rentalId);
1141
+ const stay = useStay(stayId, rentalId);
1142
+ const calendar = useStateValue(statePath("availability", rentalId)) ?? null;
1143
+ const months = calendar ? monthsOf(calendar) : [];
1144
+ const [shown, setShown] = useState(() => Math.max(0, stay ? months.indexOf(stay.start.slice(0, 7)) : 0));
1145
+ const [arrival, setArrival] = useState(null);
1146
+ if (!calendar || months.length === 0) return null;
1147
+ const month = months[Math.min(shown, months.length - 1)];
1148
+ const weekdays = Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(dateLocale(locale), {
1149
+ weekday: "short",
1150
+ timeZone: "UTC"
1151
+ }).format(day(`2024-01-0${i + 1}`)));
1152
+ const title = new Intl.DateTimeFormat(dateLocale(locale), {
1153
+ month: "long",
1154
+ year: "numeric",
1155
+ timeZone: "UTC"
1156
+ }).format(day(`${month}-01`));
1157
+ const inStay = (date) => Boolean(stay && date >= stay.start && date <= stay.end);
1158
+ const fullDate = new Intl.DateTimeFormat(dateLocale(locale), {
1159
+ weekday: "long",
1160
+ day: "numeric",
1161
+ month: "long",
1162
+ timeZone: "UTC"
1163
+ });
1164
+ const sentDay = new Intl.DateTimeFormat(dateLocale(locale), {
1165
+ weekday: "short",
1166
+ day: "numeric",
1167
+ month: "short",
1168
+ year: "numeric",
1169
+ timeZone: "UTC"
1170
+ });
1171
+ const statusText = {
1172
+ free: copy.free,
1173
+ booked: copy.booked,
1174
+ closed: copy.closed
1175
+ };
1176
+ const levelText = {
1177
+ 1: copy.cheaper,
1178
+ 2: null,
1179
+ 3: copy.dearer
1180
+ };
1181
+ const arrivals = new Set(calendar.arrivals ?? []);
1182
+ const levels = calendar.levels ?? {};
1183
+ const hasLevels = new Set(Object.values(levels)).size > 1;
1184
+ const tappable = Boolean(send) && !busy;
1185
+ const chosen = tappable && arrival && arrivals.has(arrival) ? arrival : null;
1186
+ const ask = (date) => {
1187
+ const words = copy.arrivalOn.replace("{date}", sentDay.format(day(date)));
1188
+ setArrival(null);
1189
+ send?.(rental ? `${rental.name}: ${words}` : words);
1190
+ };
1191
+ return /* @__PURE__ */ jsxs("div", {
1192
+ className: "calendar",
1193
+ children: [
1194
+ /* @__PURE__ */ jsxs("div", {
1195
+ className: "calendar-head",
1196
+ children: [
1197
+ /* @__PURE__ */ jsx("button", {
1198
+ type: "button",
1199
+ "aria-label": copy.previous,
1200
+ disabled: shown === 0,
1201
+ onClick: () => setShown(shown - 1),
1202
+ children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
1203
+ }),
1204
+ /* @__PURE__ */ jsxs("span", { children: [title, rental ? ` · ${rental.name}` : ""] }),
1205
+ /* @__PURE__ */ jsx("button", {
1206
+ type: "button",
1207
+ "aria-label": copy.next,
1208
+ disabled: shown >= months.length - 1,
1209
+ onClick: () => setShown(shown + 1),
1210
+ children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
1211
+ })
1212
+ ]
1213
+ }),
1214
+ /* @__PURE__ */ jsxs("div", {
1215
+ className: "calendar-grid",
1216
+ children: [weekdays.map((weekday) => /* @__PURE__ */ jsx("span", {
1217
+ className: "weekday",
1218
+ children: weekday
1219
+ }, weekday)), monthGrid(month).map((date, index) => {
1220
+ if (!date) return /* @__PURE__ */ jsx("span", {}, `pad-${index}`);
1221
+ const status = calendar.days[date] ?? "closed";
1222
+ const canArrive = status === "free" && arrivals.has(date);
1223
+ const level = hasLevels && status === "free" ? levels[date] : void 0;
1224
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [
1225
+ /* @__PURE__ */ jsx("span", {
1226
+ "aria-hidden": "true",
1227
+ children: Number(date.slice(8))
1228
+ }),
1229
+ level && /* @__PURE__ */ jsx(Ticks, { level }),
1230
+ /* @__PURE__ */ jsx("span", {
1231
+ className: "sr-only",
1232
+ children: [
1233
+ fullDate.format(day(date)),
1234
+ statusText[status],
1235
+ ...canArrive ? [copy.arrivalPossible] : [],
1236
+ ...level && levelText[level] ? [levelText[level]] : [],
1237
+ ...inStay(date) ? [copy.yourStay] : []
1238
+ ].join(", ")
1239
+ })
1240
+ ] });
1241
+ const marks = {
1242
+ className: "day",
1243
+ "data-status": status,
1244
+ "data-arrival": canArrive || void 0,
1245
+ "data-stay": inStay(date) || void 0
1246
+ };
1247
+ return canArrive && tappable ? /* @__PURE__ */ jsx("button", {
1248
+ type: "button",
1249
+ ...marks,
1250
+ "aria-pressed": date === chosen,
1251
+ onClick: () => setArrival(date),
1252
+ children: content
1253
+ }, date) : /* @__PURE__ */ jsx("span", {
1254
+ ...marks,
1255
+ children: content
1256
+ }, date);
1257
+ })]
1258
+ }),
1259
+ /* @__PURE__ */ jsxs("div", {
1260
+ className: "legend muted",
1261
+ children: [
1262
+ /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-status": "free" }), copy.free] }),
1263
+ arrivals.size > 0 && /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-arrival": "true" }), copy.arrivalPossible] }),
1264
+ /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-status": "booked" }), copy.booked] }),
1265
+ stay && /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-stay": "true" }), copy.yourStay] }),
1266
+ hasLevels && /* @__PURE__ */ jsxs("span", {
1267
+ className: "legend-levels",
1268
+ children: [
1269
+ copy.cheaper,
1270
+ /* @__PURE__ */ jsx(Ticks, { level: 1 }),
1271
+ /* @__PURE__ */ jsx(Ticks, { level: 3 }),
1272
+ copy.dearer
1273
+ ]
1274
+ })
1275
+ ]
1276
+ }),
1277
+ chosen && /* @__PURE__ */ jsxs("div", {
1278
+ className: "cal-foot",
1279
+ children: [/* @__PURE__ */ jsxs("span", { children: [
1280
+ copy.arrivalOn.split("{date}")[0],
1281
+ /* @__PURE__ */ jsx("strong", { children: weekdayDay(chosen, locale) }),
1282
+ copy.arrivalOn.split("{date}")[1]
1283
+ ] }), /* @__PURE__ */ jsxs("button", {
1284
+ type: "button",
1285
+ className: "button compact",
1286
+ onClick: () => ask(chosen),
1287
+ children: [copy.showPrice, /* @__PURE__ */ jsx(Arrow, {})]
1288
+ })]
1289
+ })
1290
+ ]
1291
+ });
1292
+ };
1293
+ /** One rental of a comparison; its stay marked when its dates are not the first column's. */
1294
+ const CompareColumn = ({ rentalId, stayId, reference }) => {
1295
+ const { copy, locale } = useChat();
1296
+ const rental = useRental(rentalId);
1297
+ const stay = useStay(stayId, rentalId);
1298
+ const otherDates = stay && reference && (stay.start !== reference.start || stay.end !== reference.end);
1299
+ const href = useBookHref(rentalId, stay);
1300
+ if (!rental) return null;
1301
+ const rooms = rental.rooms.slice(0, 3);
1302
+ const highlights = rental.highlights.filter((highlight) => !rooms.includes(highlight));
1303
+ return /* @__PURE__ */ jsxs("div", {
1304
+ className: "compare-col",
1305
+ children: [
1306
+ /* @__PURE__ */ jsx("strong", {
1307
+ title: rental.name,
1308
+ children: rental.name
1309
+ }),
1310
+ /* @__PURE__ */ jsxs("dl", { children: [
1311
+ /* @__PURE__ */ jsx("dt", { children: copy.compareRating }),
1312
+ /* @__PURE__ */ jsx("dd", { children: rental.rating ? /* @__PURE__ */ jsxs(Fragment, { children: [
1313
+ /* @__PURE__ */ jsx("span", {
1314
+ className: "rating",
1315
+ children: "★"
1316
+ }),
1317
+ " ",
1318
+ `${rental.rating.value} (${counted(copy.reviews, reviewCount(rental.rating.count), locale)})`
1319
+ ] }) : "–" }),
1320
+ /* @__PURE__ */ jsx("dt", { children: copy.compareRooms }),
1321
+ /* @__PURE__ */ jsx("dd", { children: rooms.map(keepCount).join(", ") || "–" }),
1322
+ /* @__PURE__ */ jsx("dt", { children: copy.compareHighlights }),
1323
+ /* @__PURE__ */ jsx("dd", { children: highlights.slice(0, 4).map(keepCount).join(", ") || "–" }),
1324
+ stay && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("dt", { children: copy.compareStay }), /* @__PURE__ */ jsxs("dd", { children: [
1325
+ shortPeriod(stay, locale),
1326
+ otherDates && /* @__PURE__ */ jsx("span", {
1327
+ className: "other-date",
1328
+ children: copy.otherDate
1329
+ }),
1330
+ /* @__PURE__ */ jsx("br", {}),
1331
+ /* @__PURE__ */ jsx("span", {
1332
+ className: "muted",
1333
+ children: partyText(stay, copy, locale)
1334
+ }),
1335
+ /* @__PURE__ */ jsx("br", {}),
1336
+ !stay.bookable ? /* @__PURE__ */ jsx("span", {
1337
+ className: "muted",
1338
+ children: copy.notBookable
1339
+ }) : stay.total && /* @__PURE__ */ jsxs(Fragment, { children: [
1340
+ /* @__PURE__ */ jsx(PriceLabel, { stay }),
1341
+ /* @__PURE__ */ jsx("br", {}),
1342
+ /* @__PURE__ */ jsxs("span", {
1343
+ className: "price-line",
1344
+ children: [/* @__PURE__ */ jsx("span", {
1345
+ className: "price",
1346
+ children: stay.total
1347
+ }), /* @__PURE__ */ jsx(DealBadge, { stay })]
1348
+ })
1349
+ ] })
1350
+ ] })] })
1351
+ ] }),
1352
+ /* @__PURE__ */ jsxs(ReplyLink, {
1353
+ className: "button",
1354
+ href,
1355
+ children: [stay?.bookable ? copy.book : copy.view, /* @__PURE__ */ jsx(LinkTarget, {
1356
+ rentalId,
1357
+ stay
1358
+ })]
1359
+ })
1360
+ ]
1361
+ });
1362
+ };
1363
+ /** One arrival of a price calendar: its bar, the PMS's total, the day; tapping asks for that stay. */
1364
+ const PriceColumn = ({ rentalId, option, asked, cheapest }) => {
1365
+ const { copy, locale, send, busy } = useChat();
1366
+ const rental = useRental(rentalId);
1367
+ const stay = useStay(option.stayId, rentalId);
1368
+ const free = Boolean(stay?.bookable && stay.total);
1369
+ const arrival = (options) => new Intl.DateTimeFormat(dateLocale(locale), {
1370
+ ...options,
1371
+ timeZone: "UTC"
1372
+ }).format(day(option.start));
1373
+ const dates = period(option, locale);
1374
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
1375
+ type: "button",
1376
+ className: "price-column",
1377
+ "data-asked": asked || void 0,
1378
+ "data-cheapest": cheapest || void 0,
1379
+ "data-free": free || void 0,
1380
+ disabled: !free || !send || busy,
1381
+ "aria-label": [
1382
+ dates,
1383
+ free ? stay.total : copy.closed,
1384
+ ...cheapest ? [copy.cheapest] : [],
1385
+ ...asked ? [copy.yourStay] : []
1386
+ ].join(", "),
1387
+ onClick: () => send?.(rental ? `${rental.name}: ${dates}` : dates),
1388
+ children: [
1389
+ /* @__PURE__ */ jsx("span", {
1390
+ className: "bar-track",
1391
+ "aria-hidden": "true",
1392
+ children: free && /* @__PURE__ */ jsx("span", {
1393
+ className: "bar",
1394
+ style: { height: `${25 + 75 * (option.level ?? 1)}%` }
1395
+ })
1396
+ }),
1397
+ /* @__PURE__ */ jsx("span", {
1398
+ className: "price-text",
1399
+ "aria-hidden": "true",
1400
+ children: free ? stay.total : copy.closed
1401
+ }),
1402
+ /* @__PURE__ */ jsxs("span", {
1403
+ className: "muted",
1404
+ "aria-hidden": "true",
1405
+ children: [
1406
+ arrival({ weekday: "short" }),
1407
+ /* @__PURE__ */ jsx("br", {}),
1408
+ arrival({
1409
+ day: "numeric",
1410
+ month: "short"
1411
+ })
1412
+ ]
1413
+ })
1414
+ ]
1415
+ }) });
1416
+ };
1417
+ /** Which ends of a scrolled row have more beyond them, for the fade there. */
1418
+ const hiddenEdges = (row) => {
1419
+ const start = row.scrollLeft > 4;
1420
+ const end = row.scrollLeft + row.clientWidth < row.scrollWidth - 4;
1421
+ return start && end ? "both" : start ? "start" : end ? "end" : void 0;
1422
+ };
1423
+ const PriceCalendarView = ({ rentalId }) => {
1424
+ const { copy, locale } = useChat();
1425
+ const rental = useRental(rentalId);
1426
+ const calendar = useStateValue(statePath("priceCalendars", rentalId)) ?? null;
1427
+ const row = useRef(null);
1428
+ const [fade, setFade] = useState();
1429
+ useEffect(() => {
1430
+ const element = row.current;
1431
+ if (!element || !calendar) return;
1432
+ const cheapest = calendar.options.findIndex((option) => option.stayId !== null && option.stayId === calendar.cheapest);
1433
+ const asked = calendar.options.findIndex((option) => option.start === calendar.asked);
1434
+ const column = element.children[cheapest >= 0 ? cheapest : asked];
1435
+ if (column) {
1436
+ const end = column.offsetLeft + column.offsetWidth;
1437
+ if (end > element.clientWidth) element.scrollLeft = end - element.clientWidth;
1438
+ }
1439
+ setFade(hiddenEdges(element));
1440
+ }, [calendar]);
1441
+ if (!calendar || calendar.options.length === 0) return null;
1442
+ return /* @__PURE__ */ jsx("section", {
1443
+ className: "card price-calendar",
1444
+ "aria-label": copy.priceCalendar,
1445
+ children: /* @__PURE__ */ jsxs("div", {
1446
+ className: "card-body",
1447
+ children: [
1448
+ /* @__PURE__ */ jsx("h3", {
1449
+ className: "card-title",
1450
+ children: copy.priceCalendar
1451
+ }),
1452
+ /* @__PURE__ */ jsxs("span", {
1453
+ className: "muted",
1454
+ children: [counted(copy.priceCalendarNights, calendar.nights, locale), rental ? ` · ${rental.name}` : ""]
1455
+ }),
1456
+ /* @__PURE__ */ jsx("ul", {
1457
+ ref: row,
1458
+ className: "price-columns",
1459
+ "data-fade": fade,
1460
+ onScroll: (event) => setFade(hiddenEdges(event.currentTarget)),
1461
+ children: calendar.options.map((option) => /* @__PURE__ */ jsx(PriceColumn, {
1462
+ rentalId,
1463
+ option,
1464
+ asked: option.start === calendar.asked,
1465
+ cheapest: option.stayId !== null && option.stayId === calendar.cheapest
1466
+ }, option.start))
1467
+ }),
1468
+ /* @__PURE__ */ jsxs("div", {
1469
+ className: "legend muted",
1470
+ 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] })]
1471
+ })
1472
+ ]
1473
+ })
1474
+ });
1475
+ };
1476
+ const ReviewsView = ({ rentalId }) => {
1477
+ const { copy, locale } = useChat();
1478
+ const rental = useRental(rentalId);
1479
+ const reviews = useStateValue(statePath("reviews", rentalId)) ?? null;
1480
+ if (!reviews) return null;
1481
+ return /* @__PURE__ */ jsx("section", {
1482
+ className: "card reviews",
1483
+ "aria-label": copy.reviewsTitle,
1484
+ children: /* @__PURE__ */ jsxs("div", {
1485
+ className: "card-body",
1486
+ children: [
1487
+ /* @__PURE__ */ jsxs("h3", {
1488
+ className: "card-title",
1489
+ children: [copy.reviewsTitle, rental ? ` · ${rental.name}` : ""]
1490
+ }),
1491
+ /* @__PURE__ */ jsxs("div", {
1492
+ className: "score",
1493
+ children: [/* @__PURE__ */ jsx("span", {
1494
+ className: "score-value",
1495
+ children: reviews.rating
1496
+ }), /* @__PURE__ */ jsxs("span", { children: [reviews.grade && /* @__PURE__ */ jsx("strong", { children: reviews.grade }), /* @__PURE__ */ jsxs("span", {
1497
+ className: "muted",
1498
+ children: [" ", counted(copy.reviews, reviewCount(reviews.count), locale)]
1499
+ })] })]
1500
+ }),
1501
+ reviews.categories.length > 0 && /* @__PURE__ */ jsx("dl", {
1502
+ className: "review-categories",
1503
+ children: reviews.categories.map((category) => /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", { children: category.label }), /* @__PURE__ */ jsx("dd", { children: category.rating })] }, category.label))
1504
+ }),
1505
+ reviews.excerpts.map((excerpt) => /* @__PURE__ */ jsx("blockquote", {
1506
+ className: "excerpt",
1507
+ children: /* @__PURE__ */ jsx("p", { children: excerpt.text })
1508
+ }, excerpt.text))
1509
+ ]
1510
+ })
1511
+ });
1512
+ };
1513
+ const FactsView = ({ rentalId, groups }) => {
1514
+ const rental = useRental(rentalId);
1515
+ const known = useStateValue(statePath("facts", rentalId)) ?? [];
1516
+ const shown = groups.flatMap((label) => {
1517
+ const group = known.find((candidate) => candidate.label.toLowerCase() === label.toLowerCase());
1518
+ return group ? [group] : [];
1519
+ });
1520
+ if (!rental || shown.length === 0) return null;
1521
+ return /* @__PURE__ */ jsx("section", {
1522
+ className: "card facts",
1523
+ "aria-label": rental.name,
1524
+ children: /* @__PURE__ */ jsxs("div", {
1525
+ className: "card-body",
1526
+ children: [/* @__PURE__ */ jsx("h3", {
1527
+ className: "card-title",
1528
+ children: rental.name
1529
+ }), shown.map((group) => /* @__PURE__ */ jsxs("div", {
1530
+ className: "fact-group",
1531
+ children: [/* @__PURE__ */ jsx("h4", { children: group.label }), /* @__PURE__ */ jsx("ul", { children: group.items.map((item) => /* @__PURE__ */ jsx("li", { children: item }, item)) })]
1532
+ }, group.label))]
1533
+ })
1534
+ });
1535
+ };
1536
+ const RentalMapView = ({ rentalId }) => {
1537
+ const { copy } = useChat();
1538
+ const rental = useRental(rentalId);
1539
+ const href = useBookHref(rentalId, null);
1540
+ if (!rental) return null;
1541
+ return /* @__PURE__ */ jsx(MapCard, {
1542
+ rental,
1543
+ label: copy.mapLabel.replace("{name}", rental.name),
1544
+ notice: copy.mapNotice,
1545
+ load: copy.mapLoad,
1546
+ children: /* @__PURE__ */ jsxs("div", {
1547
+ className: "card-body",
1548
+ children: [
1549
+ /* @__PURE__ */ jsx("h3", {
1550
+ className: "card-title",
1551
+ children: rental.name
1552
+ }),
1553
+ rental.place && /* @__PURE__ */ jsx("span", {
1554
+ className: "muted",
1555
+ children: rental.place
1556
+ }),
1557
+ /* @__PURE__ */ jsx(ReplyLink, {
1558
+ className: "button",
1559
+ href,
1560
+ children: copy.viewRental
1561
+ })
1562
+ ]
1563
+ })
1564
+ });
1565
+ };
1566
+ /** The registry the panel renders specs with. */
1567
+ const { registry } = defineRegistry(chatCatalog, { components: {
1568
+ Stack: ({ children }) => /* @__PURE__ */ jsx("div", {
1569
+ className: "ui",
1570
+ children
1571
+ }),
1572
+ RentalCard: ({ props }) => /* @__PURE__ */ jsx(Card, {
1573
+ rentalId: props.rentalId,
1574
+ stayId: props.stayId
1575
+ }),
1576
+ RentalCarousel: ({ props }) => {
1577
+ const { copy } = useChat();
1578
+ const stays = useStateValue("/stays") ?? {};
1579
+ const dates = new Set(props.items.flatMap((item) => {
1580
+ const stay = item.stayId ? stays[item.stayId] : void 0;
1581
+ return stay ? [`${stay.start}/${stay.end}`] : [];
1582
+ }));
1583
+ return /* @__PURE__ */ jsx(Snapper, {
1584
+ count: props.items.length,
1585
+ className: "carousel",
1586
+ label: copy.rentalsFound,
1587
+ ...dates.size > 1 && { caption: copy.datesDiffer },
1588
+ children: props.items.map((item) => /* @__PURE__ */ jsx(Card, {
1589
+ rentalId: item.rentalId,
1590
+ stayId: item.stayId,
1591
+ inRow: true
1592
+ }, `${item.rentalId}:${item.stayId}`))
1593
+ });
1594
+ },
1595
+ StayQuote: ({ props }) => /* @__PURE__ */ jsx(Quote, {
1596
+ stayId: props.stayId,
1597
+ handover: props.handover ?? false
1598
+ }),
1599
+ PriceBreakdown: ({ props }) => {
1600
+ const { copy, locale } = useChat();
1601
+ const stay = useStay(props.stayId);
1602
+ const rental = useRental(stay?.rentalId ?? "");
1603
+ if (!stay?.bookable || !stay.total) return null;
1604
+ const label = rental ? `${copy.priceBreakdown} · ${rental.name}` : copy.priceBreakdown;
1605
+ return /* @__PURE__ */ jsx("section", {
1606
+ className: "card breakdown",
1607
+ "aria-label": label,
1608
+ children: /* @__PURE__ */ jsxs("div", {
1609
+ className: "card-body",
1610
+ children: [
1611
+ /* @__PURE__ */ jsx("span", {
1612
+ className: "muted",
1613
+ children: label
1614
+ }),
1615
+ /* @__PURE__ */ jsxs("span", {
1616
+ className: "foot-meta",
1617
+ children: [
1618
+ stayMeta(stay, copy, locale),
1619
+ " · ",
1620
+ partyText(stay, copy, locale)
1621
+ ]
1622
+ }),
1623
+ stay.lines.map((line) => /* @__PURE__ */ jsxs("div", {
1624
+ className: "line",
1625
+ children: [/* @__PURE__ */ jsx("span", { children: line.label }), /* @__PURE__ */ jsx("span", { children: line.amount })]
1626
+ }, `${line.label}:${line.amount}`)),
1627
+ /* @__PURE__ */ jsxs("div", {
1628
+ className: "line total",
1629
+ children: [/* @__PURE__ */ jsx("span", { children: copy.total }), /* @__PURE__ */ jsx("span", { children: stay.total })]
1630
+ })
1631
+ ]
1632
+ })
1633
+ });
1634
+ },
1635
+ AlternativeStays: ({ props }) => {
1636
+ const { copy, locale, busy } = useChat();
1637
+ const rental = useRental(props.rentalId);
1638
+ const stays = useStateValue("/stays") ?? {};
1639
+ const [chosen, choose] = useSelection(props.rentalId);
1640
+ const bookable = props.stayIds.flatMap((stayId) => {
1641
+ const stay = stays[stayId];
1642
+ return stay?.rentalId === props.rentalId && stay.bookable ? [stay] : [];
1643
+ });
1644
+ if (bookable.length === 0) return null;
1645
+ const label = rental ? `${copy.otherDates} · ${rental.name}` : copy.otherDates;
1646
+ const photo = rental?.images[0];
1647
+ return /* @__PURE__ */ jsxs("section", {
1648
+ className: "card alternatives",
1649
+ "aria-label": label,
1650
+ children: [/* @__PURE__ */ jsxs("div", {
1651
+ className: "rows-head",
1652
+ children: [photo && /* @__PURE__ */ jsx("img", {
1653
+ className: "thumb",
1654
+ src: photo.src,
1655
+ srcSet: photo.srcset,
1656
+ sizes: "40px",
1657
+ alt: ""
1658
+ }), /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("strong", { children: rental?.name ?? copy.otherDates }), /* @__PURE__ */ jsx("span", {
1659
+ className: "muted",
1660
+ children: [rental?.place, partyText(bookable[0], copy, locale)].filter(Boolean).join(" · ")
1661
+ })] })]
1662
+ }), /* @__PURE__ */ jsx("div", {
1663
+ className: "rows",
1664
+ children: bookable.map((stay) => /* @__PURE__ */ jsxs("button", {
1665
+ type: "button",
1666
+ className: "row",
1667
+ "aria-pressed": stay.id === chosen,
1668
+ "data-stay-id": stay.id,
1669
+ disabled: busy || !stay.total,
1670
+ onClick: () => choose(stay.id),
1671
+ children: [
1672
+ /* @__PURE__ */ jsx("span", {
1673
+ className: "radio",
1674
+ "aria-hidden": "true",
1675
+ children: stay.id === chosen && /* @__PURE__ */ jsx(Check, {})
1676
+ }),
1677
+ /* @__PURE__ */ jsxs("span", {
1678
+ className: "row-when",
1679
+ children: [/* @__PURE__ */ jsx("strong", { children: weekdayPeriod(stay, locale) }), /* @__PURE__ */ jsxs("span", { children: [counted(copy.nights, nightsOf(stay), locale), /* @__PURE__ */ jsx(DealBadge, { stay })] })]
1680
+ }),
1681
+ stay.total && /* @__PURE__ */ jsx("span", {
1682
+ className: "price",
1683
+ children: stay.total
1684
+ })
1685
+ ]
1686
+ }, stay.id))
1687
+ })]
1688
+ });
1689
+ },
1690
+ AvailabilityCalendar: ({ props }) => /* @__PURE__ */ jsx(Calendar, {
1691
+ rentalId: props.rentalId,
1692
+ stayId: props.stayId
1693
+ }),
1694
+ PriceCalendar: ({ props }) => /* @__PURE__ */ jsx(PriceCalendarView, { rentalId: props.rentalId }),
1695
+ ReviewSummary: ({ props }) => /* @__PURE__ */ jsx(ReviewsView, { rentalId: props.rentalId }),
1696
+ RentalFacts: ({ props }) => /* @__PURE__ */ jsx(FactsView, {
1697
+ rentalId: props.rentalId,
1698
+ groups: props.groups
1699
+ }),
1700
+ RentalMap: ({ props }) => /* @__PURE__ */ jsx(RentalMapView, { rentalId: props.rentalId }),
1701
+ Compare: ({ props }) => {
1702
+ const stays = useStateValue("/stays") ?? {};
1703
+ const first = props.items[0]?.stayId;
1704
+ const reference = first && stays[first] || null;
1705
+ return /* @__PURE__ */ jsx("div", {
1706
+ className: "compare",
1707
+ children: props.items.map((item) => /* @__PURE__ */ jsx(CompareColumn, {
1708
+ rentalId: item.rentalId,
1709
+ stayId: item.stayId,
1710
+ reference
1711
+ }, `${item.rentalId}:${item.stayId}`))
1712
+ });
1713
+ },
1714
+ StayLink: ({ props }) => {
1715
+ const stay = useStay(props.stayId, props.rentalId);
1716
+ return /* @__PURE__ */ jsx(BookLink, {
1717
+ rentalId: props.rentalId,
1718
+ stay
1719
+ });
1720
+ },
1721
+ ContactLink: () => {
1722
+ const { copy, contactPath } = useChat();
1723
+ return /* @__PURE__ */ jsx(ReplyLink, {
1724
+ className: "link",
1725
+ href: chatHref(contactPath),
1726
+ children: copy.contactLink
1727
+ });
1728
+ },
1729
+ QuickReplies: ({ props }) => {
1730
+ const { send, busy, live = true, answered } = useChat();
1731
+ const options = live ? props.options : props.options.filter((option) => option === answered);
1732
+ if (!send || options.length === 0) return null;
1733
+ return /* @__PURE__ */ jsx("div", {
1734
+ className: "quick-replies",
1735
+ children: options.map((option) => /* @__PURE__ */ jsx("button", {
1736
+ type: "button",
1737
+ className: "chip",
1738
+ "data-chosen": !live || void 0,
1739
+ disabled: busy || !live,
1740
+ onClick: () => send(option),
1741
+ children: option
1742
+ }, option))
1743
+ });
1744
+ }
1745
+ } });
1746
+ //#endregion
1747
+ //#region src/dock.tsx
1748
+ const CHOOSING = /* @__PURE__ */ new Set(["RentalCarousel", "Compare"]);
1749
+ const specsOf = (message) => message.parts.flatMap((part) => part.kind === "ui" ? [part.spec] : []);
1750
+ const stateOf = (spec) => spec.state ?? {};
1751
+ /** The reply's verdict on the pick, or `undefined` when it shows no stays and the older ones decide. */
1752
+ const pickIn = (message, index) => {
1753
+ let quoted = false;
1754
+ const priced = /* @__PURE__ */ new Map();
1755
+ for (const spec of specsOf(message)) {
1756
+ const state = stateOf(spec);
1757
+ for (const element of Object.values(spec.elements ?? {})) {
1758
+ if (CHOOSING.has(element.type)) return null;
1759
+ if (element.type !== "StayQuote" && element.type !== "RentalCard") continue;
1760
+ const stay = state.stays?.[String(element.props.stayId)];
1761
+ const shown = stay?.bookable && stay.total;
1762
+ if (element.type === "StayQuote" || shown) quoted = true;
1763
+ if (shown) priced.set(stay.id, {
1764
+ stay,
1765
+ state
1766
+ });
1767
+ }
1768
+ }
1769
+ if (!quoted) return void 0;
1770
+ const [only, ...more] = priced.values();
1771
+ return only && more.length === 0 ? {
1772
+ kind: "pick",
1773
+ stay: only.stay,
1774
+ state: only.state,
1775
+ message: index
1776
+ } : null;
1777
+ };
1778
+ /** See {@link DockOf}. */
1779
+ const dockOf = (messages, picked) => {
1780
+ let decided = {
1781
+ index: -1,
1782
+ pick: null
1783
+ };
1784
+ for (let index = messages.length - 1; index >= 0; index--) {
1785
+ const message = messages[index];
1786
+ if (message.role !== "assistant") continue;
1787
+ const pick = pickIn(message, index);
1788
+ if (pick !== void 0) {
1789
+ decided = {
1790
+ index,
1791
+ pick
1792
+ };
1793
+ break;
1794
+ }
1795
+ }
1796
+ if (picked && picked.after > decided.index) {
1797
+ const part = messages[picked.message]?.parts[picked.part];
1798
+ const state = part?.kind === "ui" ? stateOf(part.spec) : {};
1799
+ const stay = state.stays?.[picked.stayId];
1800
+ if (stay?.bookable && stay.total) return {
1801
+ kind: "pick",
1802
+ stay,
1803
+ state,
1804
+ message: picked.message
1805
+ };
1806
+ }
1807
+ if (decided.pick) return decided.pick;
1808
+ for (let index = messages.length - 1; index >= 0; index--) {
1809
+ const message = messages[index];
1810
+ if (message.role !== "assistant") continue;
1811
+ const specs = specsOf(message);
1812
+ for (let at = specs.length - 1; at >= 0; at--) {
1813
+ const trip = stateOf(specs[at]).trip;
1814
+ if (trip) return {
1815
+ kind: "trip",
1816
+ trip
1817
+ };
1818
+ }
1819
+ }
1820
+ return null;
1821
+ };
1822
+ /** "Juli–Aug. 2027" from `YYYY-MM` months; "Juni, Sept. 2027" when they leave a gap, each month with
1823
+ * its year when they span two ("Dez. 2026, Feb. 2027"). */
1824
+ const monthsText = (months, locale) => {
1825
+ const sorted = [...new Set(months)].sort();
1826
+ const first = sorted[0];
1827
+ const last = sorted.at(-1);
1828
+ const withYear = new Intl.DateTimeFormat(dateLocale(locale), {
1829
+ month: "short",
1830
+ year: "numeric",
1831
+ timeZone: "UTC"
1832
+ });
1833
+ if ((Number(last.slice(0, 4)) - Number(first.slice(0, 4))) * 12 + Number(last.slice(5, 7)) - Number(first.slice(5, 7)) === sorted.length - 1) return sorted.length === 1 ? withYear.format(day(`${first}-01`)) : withYear.formatRange(day(`${first}-01`), day(`${last}-01`));
1834
+ const inDate = new Intl.DateTimeFormat(dateLocale(locale), {
1835
+ day: "numeric",
1836
+ month: "short",
1837
+ timeZone: "UTC"
1838
+ });
1839
+ const month = (value) => inDate.formatToParts(day(`${value}-01`)).find((part) => part.type === "month").value;
1840
+ if (first.slice(0, 4) !== last.slice(0, 4)) return sorted.map((value) => `${month(value)} ${value.slice(0, 4)}`).join(", ");
1841
+ return `${sorted.map(month).join(", ")} ${last.slice(0, 4)}`;
1842
+ };
1843
+ /** The trip's parts as the bar's chips, each with what a tap puts into the input. */
1844
+ const tripChips = (trip, copy, locale) => {
1845
+ const chips = [];
1846
+ const { period, party } = trip;
1847
+ if (period?.kind === "dates") chips.push({
1848
+ key: "period",
1849
+ text: shortPeriod(period, locale),
1850
+ starter: "period"
1851
+ });
1852
+ if (period?.kind === "months") chips.push({
1853
+ key: "period",
1854
+ text: monthsText(period.months, locale),
1855
+ starter: "period"
1856
+ });
1857
+ if (period?.kind === "windows" && period.windows.length > 0) chips.push({
1858
+ key: "period",
1859
+ text: [...period.windows].sort((a, b) => a.from.localeCompare(b.from)).map((window) => shortPeriod({
1860
+ start: window.from,
1861
+ end: window.to
1862
+ }, locale)).join(", "),
1863
+ starter: "period"
1864
+ });
1865
+ if (period?.kind === "months" && period.weekend) chips.push({
1866
+ key: "weekend",
1867
+ text: copy.weekend,
1868
+ starter: "period"
1869
+ });
1870
+ const children = party.children + party.babies;
1871
+ chips.push({
1872
+ key: "party",
1873
+ text: [
1874
+ counted(copy.adultsShort, party.adults, locale),
1875
+ ...children ? [counted(copy.children, children, locale)] : [],
1876
+ ...party.pets ? [counted(copy.pets, party.pets, locale)] : []
1877
+ ].join(" + "),
1878
+ starter: "party",
1879
+ assumed: trip.partyAssumed
1880
+ });
1881
+ const nights = period?.kind === "dates" ? null : period?.nights;
1882
+ if (nights) chips.push({
1883
+ key: "nights",
1884
+ text: counted(copy.nights, nights, locale),
1885
+ starter: "nights"
1886
+ });
1887
+ for (const wish of trip.wishes) chips.push({
1888
+ key: `wish:${wish}`,
1889
+ text: wish,
1890
+ starter: "wishes"
1891
+ });
1892
+ return chips;
1893
+ };
1894
+ const Pick = ({ dock, onShow }) => {
1895
+ const { copy, locale, searchPath } = useChat();
1896
+ const { stay, state } = dock;
1897
+ const rental = state.rentals?.[stay.rentalId] ?? null;
1898
+ const photo = rental?.images[0];
1899
+ const href = bookHref({
1900
+ page: state.pages?.[stay.rentalId] ?? null,
1901
+ booking: state.bookingPages?.[stay.rentalId] ?? null
1902
+ }, stay, searchPath);
1903
+ const { party } = stay;
1904
+ const guests = counted(copy.guests, party.adults + party.children + party.babies, locale);
1905
+ return /* @__PURE__ */ jsxs("div", {
1906
+ className: "dock-bar",
1907
+ "data-kind": "pick",
1908
+ role: "region",
1909
+ "aria-label": copy.yourPick,
1910
+ children: [
1911
+ photo && /* @__PURE__ */ jsx("img", {
1912
+ className: "thumb",
1913
+ src: photo.src,
1914
+ srcSet: photo.srcset,
1915
+ sizes: "40px",
1916
+ alt: ""
1917
+ }),
1918
+ /* @__PURE__ */ jsxs("button", {
1919
+ type: "button",
1920
+ className: "dock-text",
1921
+ onClick: onShow,
1922
+ children: [/* @__PURE__ */ jsx("span", {
1923
+ className: "dock-name",
1924
+ children: rental?.name
1925
+ }), /* @__PURE__ */ jsxs("span", {
1926
+ className: "dock-sub",
1927
+ children: [
1928
+ /* @__PURE__ */ jsx("b", {
1929
+ className: "price",
1930
+ children: stay.total
1931
+ }),
1932
+ " · ",
1933
+ shortPeriod(stay, locale),
1934
+ " · ",
1935
+ guests
1936
+ ]
1937
+ })]
1938
+ }),
1939
+ /* @__PURE__ */ jsxs(ReplyLink, {
1940
+ className: "button compact",
1941
+ href,
1942
+ children: [copy.book, /* @__PURE__ */ jsx("span", {
1943
+ className: "sr-only",
1944
+ children: rental ? `, ${rental.name}` : ""
1945
+ })]
1946
+ })
1947
+ ]
1948
+ });
1949
+ };
1950
+ const Trip = ({ trip }) => {
1951
+ const { copy, locale, suggest } = useChat();
1952
+ return /* @__PURE__ */ jsx("div", {
1953
+ className: "dock-bar",
1954
+ "data-kind": "trip",
1955
+ role: "region",
1956
+ "aria-label": copy.yourTrip,
1957
+ children: /* @__PURE__ */ jsx("div", {
1958
+ className: "trip-chips",
1959
+ children: tripChips(trip, copy, locale).map((chip) => /* @__PURE__ */ jsxs("button", {
1960
+ type: "button",
1961
+ className: "trip-chip",
1962
+ "data-assumed": chip.assumed || void 0,
1963
+ onClick: () => suggest?.(copy.tripStarters[chip.starter]),
1964
+ children: [chip.text, chip.assumed && /* @__PURE__ */ jsxs(Fragment, { children: [" ", /* @__PURE__ */ jsx("em", { children: copy.assumed })] })]
1965
+ }, chip.key))
1966
+ })
1967
+ });
1968
+ };
1969
+ /** The bar; nothing without a pick or a trip. `onShow` brings the pick's card into view. */
1970
+ const DockBar = ({ dock, onShow }) => {
1971
+ if (!dock) return null;
1972
+ return dock.kind === "pick" ? /* @__PURE__ */ jsx(Pick, {
1973
+ dock,
1974
+ onShow: () => onShow(dock.message, dock.stay.id)
1975
+ }) : /* @__PURE__ */ jsx(Trip, { trip: dock.trip });
1976
+ };
1977
+ //#endregion
1978
+ //#region src/panel.tsx
1979
+ /** Appends one event's content to the reply being streamed (the last message). */
1980
+ const appendToReply = (messages, part) => {
1981
+ const last = messages.at(-1);
1982
+ if (!last || last.role !== "assistant") return [...messages, {
1983
+ role: "assistant",
1984
+ parts: [part],
1985
+ unconfirmed: true
1986
+ }];
1987
+ const tail = last.parts.at(-1);
1988
+ const parts = part.kind === "text" && tail?.kind === "text" ? [...last.parts.slice(0, -1), {
1989
+ kind: "text",
1990
+ text: tail.text + part.text
1991
+ }] : [...last.parts, part];
1992
+ return [...messages.slice(0, -1), {
1993
+ ...last,
1994
+ parts
1995
+ }];
1996
+ };
1997
+ /** The service finished the turn: its messages, from its visitor message on, are the
1998
+ * conversation's now. An earlier turn it refused stays unconfirmed, on this page only. */
1999
+ const confirmed = (messages) => {
2000
+ const turn = messages.findLastIndex((message) => message.role === "visitor");
2001
+ return messages.map((message, index) => index >= turn && message.unconfirmed ? {
2002
+ role: message.role,
2003
+ parts: message.parts
2004
+ } : message);
2005
+ };
2006
+ /** The choices as storage indexes them: it keeps only confirmed messages, and a choice names its
2007
+ * message by index, so a refused turn before it moves it up. A choice in a dropped message goes. */
2008
+ const storedChoices = (messages, selected, picked) => {
2009
+ const kept = (before) => messages.slice(0, before).filter((message) => !message.unconfirmed).length;
2010
+ const moved = (index) => messages[index]?.unconfirmed ? null : kept(index);
2011
+ const storedSelected = {};
2012
+ for (const [key, stayId] of Object.entries(selected)) {
2013
+ const at = key.indexOf(":");
2014
+ const index = moved(Number(key.slice(0, at)));
2015
+ if (index !== null) storedSelected[`${index}${key.slice(at)}`] = stayId;
2016
+ }
2017
+ const message = picked ? moved(picked.message) : null;
2018
+ return {
2019
+ selected: storedSelected,
2020
+ picked: picked && message !== null ? {
2021
+ ...picked,
2022
+ message,
2023
+ after: kept(picked.after)
2024
+ } : null
2025
+ };
2026
+ };
2027
+ const SCROLL_LOCK = "data-v-office-chat-open";
2028
+ /** Locks the page's scroll until the returned function runs. The rule lives in the page's head (a
2029
+ * shadow root's styles cannot reach <html>) and is added once. */
2030
+ const lockPageScroll = () => {
2031
+ const root = document.documentElement;
2032
+ if (!document.getElementById(SCROLL_LOCK)) {
2033
+ const style = document.createElement("style");
2034
+ style.id = SCROLL_LOCK;
2035
+ style.textContent = `html[${SCROLL_LOCK}] { overflow: hidden; }`;
2036
+ document.head.append(style);
2037
+ }
2038
+ root.setAttribute(SCROLL_LOCK, "");
2039
+ return () => root.removeAttribute(SCROLL_LOCK);
2040
+ };
2041
+ /** The assistant's text, its `**bold**` (a rental's name, the dates) as strong text: nothing else
2042
+ * is read as markup, so the model's text never becomes HTML. */
2043
+ const RichText = ({ text }) => /* @__PURE__ */ jsx(Fragment, { children: text.split(/\*\*(.+?)\*\*/g).map((piece, index) => index % 2 === 1 ? /* @__PURE__ */ jsx("strong", { children: piece }, index) : piece) });
2044
+ /** One card spec of a message; its components learn which part of the message they are in, for the
2045
+ * stays the visitor chooses in them. */
2046
+ const SpecView = ({ spec, part }) => {
2047
+ const context = useContext(ChatContext);
2048
+ return /* @__PURE__ */ jsx(ChatContext.Provider, {
2049
+ value: {
2050
+ ...context,
2051
+ part
2052
+ },
2053
+ children: /* @__PURE__ */ jsx(JSONUIProvider, {
2054
+ registry,
2055
+ initialState: spec.state ?? {},
2056
+ children: /* @__PURE__ */ jsx(Renderer, {
2057
+ spec,
2058
+ registry
2059
+ })
2060
+ })
2061
+ });
2062
+ };
2063
+ const Consent = ({ copy, pending, privacyPath, onAccept, buttonRef }) => /* @__PURE__ */ jsxs("div", {
2064
+ className: "consent",
2065
+ children: [
2066
+ /* @__PURE__ */ jsx("p", {
2067
+ className: "message",
2068
+ "data-role": "assistant",
2069
+ children: copy.greeting
2070
+ }),
2071
+ pending && /* @__PURE__ */ jsxs("figure", {
2072
+ className: "pending",
2073
+ children: [/* @__PURE__ */ jsx("figcaption", {
2074
+ className: "muted",
2075
+ children: copy.yourMessage
2076
+ }), /* @__PURE__ */ jsx("p", { children: pending })]
2077
+ }),
2078
+ /* @__PURE__ */ jsxs("div", {
2079
+ className: "consent-card",
2080
+ children: [/* @__PURE__ */ jsxs("p", { children: [copy.consent, privacyPath && /* @__PURE__ */ jsxs(Fragment, { children: [" ", /* @__PURE__ */ jsx("a", {
2081
+ className: "link",
2082
+ href: privacyPath,
2083
+ target: "_blank",
2084
+ rel: "noopener",
2085
+ children: copy.privacyLink
2086
+ })] })] }), /* @__PURE__ */ jsx("button", {
2087
+ ref: buttonRef,
2088
+ type: "button",
2089
+ className: "button compact",
2090
+ onClick: onAccept,
2091
+ children: copy.consentAccept
2092
+ })]
2093
+ })
2094
+ ]
2095
+ });
2096
+ /** Shown until the reply's text: a search takes seconds, and three dots alone read as nothing.
2097
+ * Hidden from a screen reader: inside the busy log it may go unannounced, so the panel's own status,
2098
+ * outside the log, says it. */
2099
+ const Thinking = ({ label }) => /* @__PURE__ */ jsxs("div", {
2100
+ className: "thinking",
2101
+ "aria-hidden": "true",
2102
+ children: [
2103
+ /* @__PURE__ */ jsx("i", {}),
2104
+ /* @__PURE__ */ jsx("i", {}),
2105
+ /* @__PURE__ */ jsx("i", {}),
2106
+ /* @__PURE__ */ jsx("span", {
2107
+ className: "thinking-label",
2108
+ children: label
2109
+ })
2110
+ ]
2111
+ });
2112
+ /** Why the chat cannot answer, as its reply to the visitor's message, and where to go instead; with
2113
+ * `onRetry`, first the way to send it again. */
2114
+ const Fallback = ({ reason, copy, searchPath, contactPath, onRetry }) => /* @__PURE__ */ jsxs("div", {
2115
+ className: "fallback",
2116
+ role: "status",
2117
+ children: [/* @__PURE__ */ jsx("p", {
2118
+ className: "message",
2119
+ "data-role": "assistant",
2120
+ children: reason === "busy" ? copy.busy : copy.unavailable
2121
+ }), /* @__PURE__ */ jsxs("div", {
2122
+ className: "fallback-links",
2123
+ children: [
2124
+ onRetry && /* @__PURE__ */ jsx("button", {
2125
+ type: "button",
2126
+ className: "button",
2127
+ onClick: onRetry,
2128
+ children: copy.retry
2129
+ }),
2130
+ /* @__PURE__ */ jsx("a", {
2131
+ className: onRetry ? "link" : "button",
2132
+ href: chatHref(searchPath),
2133
+ children: copy.searchLink
2134
+ }),
2135
+ /* @__PURE__ */ jsx("a", {
2136
+ className: "link",
2137
+ href: chatHref(contactPath),
2138
+ children: copy.contactLink
2139
+ })
2140
+ ]
2141
+ })]
2142
+ });
2143
+ /** One message's place in the conversation, for its components: whether it is the newest (its quick
2144
+ * replies still choose) and what the visitor answered it with. Messages from `end` on (a refused
2145
+ * message) answer nothing. */
2146
+ const MessageContext = ({ index, messages, end, children }) => {
2147
+ const context = useContext(ChatContext);
2148
+ const next = index + 1 < end ? messages[index + 1] : void 0;
2149
+ const answered = next?.role === "visitor" && next.parts[0]?.kind === "text" ? next.parts[0].text : void 0;
2150
+ return /* @__PURE__ */ jsx(ChatContext.Provider, {
2151
+ value: {
2152
+ ...context,
2153
+ message: index,
2154
+ live: index === end - 1,
2155
+ ...answered && { answered }
2156
+ },
2157
+ children
2158
+ });
2159
+ };
2160
+ /** A refusal that a second try can get past: the message goes back into the field to send again. */
2161
+ const RETRYABLE = /* @__PURE__ */ new Set(["busy", "error"]);
2162
+ function Panel(props) {
2163
+ const { copy, endpoint, open, pending, onSent, onStarted, onClose } = props;
2164
+ const stored = useMemo(() => loadConversation(endpoint), [endpoint]);
2165
+ const [accepted, setAccepted] = useState(stored !== null);
2166
+ const [messages, setMessages] = useState(stored?.messages ?? []);
2167
+ const [selected, setSelected] = useState(stored?.selected ?? {});
2168
+ const [picked, setPicked] = useState(stored?.picked ?? null);
2169
+ const [draft, setDraft] = useState("");
2170
+ const [streaming, setStreaming] = useState(false);
2171
+ const [unavailable, setUnavailable] = useState(null);
2172
+ const [step, setStep] = useState(null);
2173
+ const [noticeOpen, setNoticeOpen] = useState(false);
2174
+ const [keyboard, setKeyboard] = useState(false);
2175
+ const session = useRef(stored?.sessionId ?? null);
2176
+ const abort = useRef(null);
2177
+ const list = useRef(null);
2178
+ const following = useRef(true);
2179
+ const lastRole = useRef(stored?.messages.at(-1)?.role);
2180
+ const start = useRef(null);
2181
+ const composer = useRef(null);
2182
+ const panel = useRef(null);
2183
+ const info = useRef(null);
2184
+ const [sheetHost, setSheetHost] = useState(null);
2185
+ const lastActivity = useRef(stored?.savedAt ?? Date.now());
2186
+ const hasMessages = messages.length > 0;
2187
+ const last = messages.at(-1);
2188
+ const waiting = streaming && !unavailable && (last?.role === "visitor" || !last?.parts.some((part) => part.kind === "text"));
2189
+ const expired = () => Date.now() - lastActivity.current >= SESSION_TTL_S * 1e3;
2190
+ const waitingLabel = step !== null && Object.hasOwn(copy.status, step) ? copy.status[step] : copy.thinking;
2191
+ const dock = useMemo(() => dockOf(messages, picked), [messages, picked]);
2192
+ const lastMessage = messages.at(-1);
2193
+ const retryable = unavailable !== null && RETRYABLE.has(unavailable) && !streaming && lastMessage?.role === "visitor" && lastMessage.parts[0]?.kind === "text" ? lastMessage.parts[0].text : null;
2194
+ const answeredEnd = unavailable !== null && !streaming && lastMessage?.role === "visitor" && lastMessage.unconfirmed ? messages.length - 1 : messages.length;
2195
+ useEffect(() => () => abort.current?.abort(), []);
2196
+ useEffect(() => {
2197
+ const role = messages.at(-1)?.role;
2198
+ const replyStarts = role === "assistant" && lastRole.current === "visitor";
2199
+ lastRole.current = role;
2200
+ const element = list.current;
2201
+ if (!open || !element) return;
2202
+ const turnTop = ([...element.querySelectorAll(":scope > .turn")].at(-1)?.offsetTop ?? 0) - 12;
2203
+ if (replyStarts) element.scrollTop = turnTop;
2204
+ else if (role === "assistant" && following.current) element.scrollTop = Math.max(element.scrollTop, Math.min(element.scrollHeight - element.clientHeight, turnTop));
2205
+ else if (role === "visitor" || following.current) element.scrollTop = element.scrollHeight;
2206
+ }, [
2207
+ messages,
2208
+ open,
2209
+ waiting,
2210
+ unavailable
2211
+ ]);
2212
+ useEffect(() => {
2213
+ if (!open) return;
2214
+ const touch = window.matchMedia?.("(pointer: coarse)").matches;
2215
+ if (accepted && hasMessages && touch) panel.current?.focus();
2216
+ else (accepted ? composer : start).current?.focus();
2217
+ }, [accepted, open]);
2218
+ useEffect(() => {
2219
+ onStarted(hasMessages);
2220
+ if (messages.at(-1)?.unconfirmed) return;
2221
+ const kept = messages.filter((message) => !message.unconfirmed);
2222
+ if (kept.length > 0) saveConversation(endpoint, {
2223
+ sessionId: session.current,
2224
+ messages: kept,
2225
+ ...storedChoices(messages, selected, picked)
2226
+ }, lastActivity.current);
2227
+ }, [
2228
+ endpoint,
2229
+ hasMessages,
2230
+ messages,
2231
+ onStarted,
2232
+ selected,
2233
+ picked
2234
+ ]);
2235
+ /** Sends one visitor message, typed or tapped (QuickReplies); nothing while a reply streams. */
2236
+ const submit = async (text) => {
2237
+ const message = text.trim().slice(0, MAX_MESSAGE_CHARS);
2238
+ if (!message || streaming) return;
2239
+ const fresh = expired();
2240
+ if (fresh) {
2241
+ session.current = null;
2242
+ clearConversation(endpoint);
2243
+ setSelected({});
2244
+ setPicked(null);
2245
+ }
2246
+ setDraft((current) => current.trim() === message.trim() ? "" : current);
2247
+ const restoreDraft = () => setDraft((current) => current || message);
2248
+ setUnavailable(null);
2249
+ setStep(null);
2250
+ setStreaming(true);
2251
+ setMessages((current) => {
2252
+ const before = fresh ? [] : current;
2253
+ const last = before.at(-1);
2254
+ return [...last?.role === "visitor" && last.unconfirmed && last.parts[0]?.kind === "text" && last.parts[0].text === message ? before.slice(0, -1) : before, {
2255
+ role: "visitor",
2256
+ parts: [{
2257
+ kind: "text",
2258
+ text: message
2259
+ }],
2260
+ unconfirmed: true
2261
+ }];
2262
+ });
2263
+ const sentAt = Date.now();
2264
+ const controller = new AbortController();
2265
+ abort.current = controller;
2266
+ try {
2267
+ await sendMessage(endpoint, {
2268
+ sessionId: session.current,
2269
+ message,
2270
+ locale: props.locale,
2271
+ clientVersion: props.clientVersion,
2272
+ picked: dock?.kind === "pick" ? dock.stay.id : null
2273
+ }, (chatEvent) => {
2274
+ if (controller.signal.aborted) return;
2275
+ if (chatEvent.type === "session") session.current = chatEvent.sessionId;
2276
+ else if (chatEvent.type === "status") setStep(chatEvent.step);
2277
+ else if (chatEvent.type === "text") {
2278
+ setStep(null);
2279
+ setMessages((current) => appendToReply(current, {
2280
+ kind: "text",
2281
+ text: chatEvent.delta
2282
+ }));
2283
+ } else if (chatEvent.type === "ui") setMessages((current) => appendToReply(current, {
2284
+ kind: "ui",
2285
+ spec: chatEvent.spec
2286
+ }));
2287
+ else if (chatEvent.type === "unavailable") {
2288
+ setStep(null);
2289
+ setUnavailable(chatEvent.reason);
2290
+ if (RETRYABLE.has(chatEvent.reason)) restoreDraft();
2291
+ } else if (chatEvent.type === "done") {
2292
+ lastActivity.current = sentAt;
2293
+ setStep(null);
2294
+ setMessages(confirmed);
2295
+ }
2296
+ }, controller.signal);
2297
+ } catch {
2298
+ if (!controller.signal.aborted) {
2299
+ setUnavailable("error");
2300
+ restoreDraft();
2301
+ }
2302
+ } finally {
2303
+ if (abort.current === controller) setStreaming(false);
2304
+ }
2305
+ };
2306
+ useEffect(() => {
2307
+ if (!open || !accepted || pending === null) return;
2308
+ onSent();
2309
+ submit(pending);
2310
+ }, [
2311
+ open,
2312
+ accepted,
2313
+ pending,
2314
+ onSent
2315
+ ]);
2316
+ /** A new conversation: the service starts a new session at the next message. */
2317
+ const restart = () => {
2318
+ abort.current?.abort();
2319
+ abort.current = null;
2320
+ setStreaming(false);
2321
+ setMessages([]);
2322
+ setSelected({});
2323
+ setPicked(null);
2324
+ setUnavailable(null);
2325
+ setStep(null);
2326
+ setNoticeOpen(false);
2327
+ session.current = null;
2328
+ clearConversation(endpoint);
2329
+ composer.current?.focus();
2330
+ };
2331
+ useEffect(() => {
2332
+ if (!open) return;
2333
+ return lockPageScroll();
2334
+ }, [open]);
2335
+ useEffect(() => {
2336
+ if (!open) return;
2337
+ document.addEventListener("astro:before-swap", onClose);
2338
+ return () => document.removeEventListener("astro:before-swap", onClose);
2339
+ }, [open, onClose]);
2340
+ useEffect(() => {
2341
+ if (open && hasMessages && !streaming && expired()) restart();
2342
+ }, [open]);
2343
+ /** Tab stays in the panel while it is open: the page behind the scrim is out of reach. */
2344
+ const trapTab = (event) => {
2345
+ const element = panel.current;
2346
+ if (event.key !== "Tab" || !element) return;
2347
+ const focusable = [...element.querySelectorAll("a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled])")];
2348
+ const active = element.getRootNode().activeElement;
2349
+ const edge = event.shiftKey ? focusable[0] : focusable.at(-1);
2350
+ const outside = !element.contains(active) || event.shiftKey && active === element;
2351
+ if (focusable.length === 0 || active === edge || outside) {
2352
+ event.preventDefault();
2353
+ (event.shiftKey ? focusable.at(-1) : focusable[0])?.focus();
2354
+ }
2355
+ };
2356
+ const send = (event) => {
2357
+ event.preventDefault();
2358
+ submit(draft);
2359
+ };
2360
+ /** A stay chosen in place: shown by its card or row, and the bar's pick until a later reply shows
2361
+ * another. */
2362
+ const select = (where, stayId) => {
2363
+ setSelected((current) => ({
2364
+ ...current,
2365
+ [`${where.message}:${where.part}:${where.rentalId}`]: stayId
2366
+ }));
2367
+ setPicked({
2368
+ stayId,
2369
+ message: where.message,
2370
+ part: where.part,
2371
+ after: messages.length
2372
+ });
2373
+ };
2374
+ /** A message's start in the field, the caret after it, for the visitor to finish. */
2375
+ const suggest = (text) => {
2376
+ setDraft(text);
2377
+ const field = composer.current;
2378
+ if (!field) return;
2379
+ field.focus();
2380
+ requestAnimationFrame(() => field.setSelectionRange(field.value.length, field.value.length));
2381
+ };
2382
+ /** Brings the bar's stay into view in the message it was quoted in, and marks it for a moment. The
2383
+ * list's own scrollTop, as for new replies. */
2384
+ const showStay = (message, stayId) => {
2385
+ const element = list.current;
2386
+ const card = [...element?.querySelectorAll(`[data-message="${message}"] [data-stay-id]`) ?? []].find((candidate) => candidate.dataset.stayId === stayId);
2387
+ if (!element || !card) return;
2388
+ element.scrollTop += card.getBoundingClientRect().top - element.getBoundingClientRect().top - 12;
2389
+ card.setAttribute("data-pulse", "");
2390
+ setTimeout(() => card.removeAttribute("data-pulse"), 1600);
2391
+ };
2392
+ return /* @__PURE__ */ jsxs(ChatContext.Provider, {
2393
+ value: {
2394
+ ...props,
2395
+ send: (text) => void submit(text),
2396
+ busy: streaming,
2397
+ suggest,
2398
+ selected,
2399
+ select,
2400
+ overlay: open ? sheetHost : null
2401
+ },
2402
+ children: [/* @__PURE__ */ jsx("div", {
2403
+ className: "scrim",
2404
+ hidden: !open,
2405
+ "aria-hidden": "true",
2406
+ onClick: onClose
2407
+ }), /* @__PURE__ */ jsxs("section", {
2408
+ className: "panel",
2409
+ role: "dialog",
2410
+ "aria-modal": "true",
2411
+ "aria-label": copy.title,
2412
+ ref: panel,
2413
+ tabIndex: -1,
2414
+ hidden: !open,
2415
+ inert: !open,
2416
+ onPointerDown: (event) => {
2417
+ setKeyboard(false);
2418
+ const target = event.target;
2419
+ if (noticeOpen && !target.closest(".notice-pop, .info")) setNoticeOpen(false);
2420
+ },
2421
+ onKeyDown: (event) => {
2422
+ if (event.key === "Tab") setKeyboard(true);
2423
+ if (event.key === "Escape") {
2424
+ if (noticeOpen) {
2425
+ setNoticeOpen(false);
2426
+ info.current?.focus();
2427
+ } else onClose();
2428
+ }
2429
+ trapTab(event);
2430
+ },
2431
+ children: [
2432
+ /* @__PURE__ */ jsxs("header", {
2433
+ className: "header",
2434
+ "data-slim": hasMessages || void 0,
2435
+ children: [
2436
+ /* @__PURE__ */ jsxs("div", {
2437
+ className: "title-block",
2438
+ children: [/* @__PURE__ */ jsxs("h2", {
2439
+ className: "title",
2440
+ children: [copy.title, /* @__PURE__ */ jsx("span", {
2441
+ className: "badge",
2442
+ children: copy.aiBadge
2443
+ })]
2444
+ }), hasMessages && /* @__PURE__ */ jsxs("p", {
2445
+ className: "notice-short",
2446
+ children: [/* @__PURE__ */ jsx("span", { children: copy.noticeShort }), /* @__PURE__ */ jsx("button", {
2447
+ ref: info,
2448
+ type: "button",
2449
+ className: "info",
2450
+ "aria-label": copy.noticeMore,
2451
+ title: copy.noticeMore,
2452
+ "aria-expanded": noticeOpen,
2453
+ onClick: () => setNoticeOpen(!noticeOpen),
2454
+ children: /* @__PURE__ */ jsx(CircleInfo, {})
2455
+ })]
2456
+ })]
2457
+ }),
2458
+ hasMessages && /* @__PURE__ */ jsx("button", {
2459
+ type: "button",
2460
+ className: "icon-button restart",
2461
+ "aria-label": copy.newConversation,
2462
+ title: copy.newConversation,
2463
+ onClick: restart,
2464
+ children: /* @__PURE__ */ jsx(Restart, {})
2465
+ }),
2466
+ /* @__PURE__ */ jsx("button", {
2467
+ type: "button",
2468
+ className: "icon-button close",
2469
+ "aria-label": copy.close,
2470
+ onClick: onClose,
2471
+ children: /* @__PURE__ */ jsx(Cross, {})
2472
+ }),
2473
+ hasMessages && noticeOpen && /* @__PURE__ */ jsx("p", {
2474
+ className: "notice-pop",
2475
+ role: "note",
2476
+ children: copy.notice
2477
+ })
2478
+ ]
2479
+ }),
2480
+ !hasMessages && /* @__PURE__ */ jsx("p", {
2481
+ className: "notice",
2482
+ children: copy.notice
2483
+ }),
2484
+ accepted ? /* @__PURE__ */ jsxs(Fragment, { children: [
2485
+ /* @__PURE__ */ jsxs("div", {
2486
+ className: "messages",
2487
+ role: "log",
2488
+ "aria-busy": streaming,
2489
+ ref: list,
2490
+ onScroll: (event) => {
2491
+ const element = event.currentTarget;
2492
+ following.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
2493
+ },
2494
+ children: [
2495
+ /* @__PURE__ */ jsx("div", {
2496
+ className: "message",
2497
+ "data-role": "assistant",
2498
+ children: copy.greeting
2499
+ }),
2500
+ messages.map((message, index) => /* @__PURE__ */ jsxs("div", {
2501
+ className: message.role === "assistant" ? "ui" : "turn",
2502
+ "data-message": index,
2503
+ children: [/* @__PURE__ */ jsxs("span", {
2504
+ className: "sr-only",
2505
+ children: [message.role === "assistant" ? copy.title : copy.yourMessage, ":"]
2506
+ }), /* @__PURE__ */ jsx(MessageContext, {
2507
+ index,
2508
+ messages,
2509
+ end: answeredEnd,
2510
+ children: message.parts.map((part, partIndex) => part.kind === "text" ? /* @__PURE__ */ jsx("div", {
2511
+ className: "message",
2512
+ "data-role": message.role,
2513
+ children: message.role === "assistant" ? /* @__PURE__ */ jsx(RichText, { text: part.text }) : part.text
2514
+ }, partIndex) : /* @__PURE__ */ jsx(SpecView, {
2515
+ spec: part.spec,
2516
+ part: partIndex
2517
+ }, partIndex))
2518
+ })]
2519
+ }, index)),
2520
+ unavailable && /* @__PURE__ */ jsx(Fallback, {
2521
+ reason: unavailable,
2522
+ copy,
2523
+ searchPath: props.searchPath,
2524
+ contactPath: props.contactPath,
2525
+ onRetry: retryable === null ? null : () => void submit(retryable)
2526
+ }),
2527
+ waiting && /* @__PURE__ */ jsx(Thinking, { label: waitingLabel })
2528
+ ]
2529
+ }),
2530
+ /* @__PURE__ */ jsx("p", {
2531
+ className: "sr-only",
2532
+ role: "status",
2533
+ children: waiting ? waitingLabel : ""
2534
+ }),
2535
+ /* @__PURE__ */ jsxs("div", {
2536
+ className: "dock",
2537
+ children: [
2538
+ /* @__PURE__ */ jsx(DockBar, {
2539
+ dock,
2540
+ onShow: showStay
2541
+ }),
2542
+ !hasMessages && copy.suggestions.length > 0 && /* @__PURE__ */ jsx("div", {
2543
+ className: "suggestions",
2544
+ children: copy.suggestions.map((suggestion) => /* @__PURE__ */ jsxs("button", {
2545
+ type: "button",
2546
+ className: "suggestion",
2547
+ onClick: () => void submit(suggestion),
2548
+ children: [/* @__PURE__ */ jsx(TopicIcon, { text: suggestion }), suggestion]
2549
+ }, suggestion))
2550
+ }),
2551
+ /* @__PURE__ */ jsxs("form", {
2552
+ className: "composer",
2553
+ "data-keyboard": keyboard || void 0,
2554
+ onSubmit: send,
2555
+ children: [/* @__PURE__ */ jsx("textarea", {
2556
+ ref: composer,
2557
+ rows: 1,
2558
+ value: draft,
2559
+ maxLength: MAX_MESSAGE_CHARS,
2560
+ placeholder: copy.placeholder,
2561
+ "aria-label": copy.placeholder,
2562
+ onChange: (event) => setDraft(event.target.value),
2563
+ onKeyDown: (event) => {
2564
+ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
2565
+ event.preventDefault();
2566
+ event.currentTarget.form?.requestSubmit();
2567
+ }
2568
+ }), /* @__PURE__ */ jsx("button", {
2569
+ type: "submit",
2570
+ className: "send",
2571
+ "aria-label": copy.send,
2572
+ disabled: streaming || !draft.trim(),
2573
+ children: /* @__PURE__ */ jsx(Arrow, {})
2574
+ })]
2575
+ })
2576
+ ]
2577
+ })
2578
+ ] }) : /* @__PURE__ */ jsx(Consent, {
2579
+ copy,
2580
+ pending,
2581
+ privacyPath: props.privacyPath,
2582
+ onAccept: () => setAccepted(true),
2583
+ buttonRef: start
2584
+ }),
2585
+ /* @__PURE__ */ jsx("div", {
2586
+ className: "sheet-host",
2587
+ ref: setSheetHost
2588
+ })
2589
+ ]
2590
+ })]
2591
+ });
2592
+ }
2593
+ //#endregion
2594
+ export { Panel as default };