@v-office/website-chat 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { i as SESSION_TTL_S, r as MAX_MESSAGE_CHARS } from "./protocol-BuNqZAMt.js";
2
- import { a as House, c as TopicIcon, d as saveConversation, i as Cross, l as clearConversation, n as Check, o as Restart, p as sendMessage, r as Chevron, t as Arrow, u as loadConversation } from "./icons-DIqOmKoe.js";
3
- import { r as chatCatalog, u as statePath } from "./catalog-y3xQ2jXW.js";
2
+ import { a as House, c as TopicIcon, d as saveConversation, i as Cross, l as clearConversation, n as Check, o as Restart, p as sendMessage, r as Chevron, t as Arrow, u as loadConversation } from "./icons-BK5Lk0cP.js";
3
+ import { r as chatCatalog, u as statePath } from "./catalog-ClcCUjf7.js";
4
4
  import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
5
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
6
  import { JSONUIProvider, Renderer, defineRegistry, useStateValue } from "@json-render/react";
@@ -23,6 +23,14 @@ const stayHref = (page, stay, searchPath) => {
23
23
  const bookingHref = (bookingPage, stay) => withQuery(bookingPage, [...stayPairs(stay), ...(stay.services ?? []).filter((service) => service.count > 0).map((service) => ["service", `${service.id}:${service.count}`])]);
24
24
  //#endregion
25
25
  //#region src/map.tsx
26
+ /** MapLibre and its worker, loaded on the first tap. MapLibre finds its worker beside its own module
27
+ * by default; bundled, the module is a hashed chunk with nothing beside it, so `?worker&url` has the
28
+ * site's bundler emit the worker as one self-contained file (MapLibre's own Vite setup), and
29
+ * `setWorkerUrl` points MapLibre at it before the first map reads it. */
30
+ const loadMapLibre = () => Promise.all([import("maplibre-gl"), import("maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url")]).then(([library, worker]) => {
31
+ library.setWorkerUrl(worker.default);
32
+ return library;
33
+ });
26
34
  /** What the style's sources ask to be credited, with their links, as MapLibre's attribution control
27
35
  * would show it (OpenStreetMap's to its copyright page). */
28
36
  const Attribution = () => /* @__PURE__ */ jsxs("p", {
@@ -61,10 +69,8 @@ const MapCard = ({ rental, label, notice, load, children }) => {
61
69
  if (!shown || !element || !location) return;
62
70
  let removed = false;
63
71
  let map;
64
- import("maplibre-gl").then((module) => {
72
+ loadMapLibre().then(({ Map, Marker }) => {
65
73
  if (removed) return;
66
- const library = module;
67
- const { Map, Marker } = library.default ?? library;
68
74
  const center = [location.lng, location.lat];
69
75
  const created = new Map({
70
76
  container: element,
@@ -124,26 +130,39 @@ const useStay = (stayId, rentalId) => {
124
130
  };
125
131
  const useRental = (rentalId) => useStateValue(statePath("rentals", rentalId)) ?? null;
126
132
  const day = (value) => /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
133
+ /** The locale dates are formatted in: British English ("13 May"), as the replies write dates and as
134
+ * European visitors read them, never the US order "5/13" that a bare "en" gives. */
135
+ const dateLocale = (locale) => locale === "en" ? "en-GB" : "de-DE";
127
136
  /** "12.–19. Juli 2027" from two `YYYY-MM-DD` days. */
128
- const period = (stay, locale) => new Intl.DateTimeFormat(locale, {
137
+ const period = (stay, locale) => new Intl.DateTimeFormat(dateLocale(locale), {
129
138
  day: "numeric",
130
139
  month: "short",
131
140
  year: "numeric",
132
141
  timeZone: "UTC"
133
142
  }).formatRange(day(stay.start), day(stay.end));
134
- /** "14.–21. Juli": the short form for choices next to each other. */
135
- const shortPeriod = (stay, locale) => new Intl.DateTimeFormat(locale, {
136
- day: "numeric",
137
- month: "short",
138
- timeZone: "UTC"
139
- }).formatRange(day(stay.start), day(stay.end));
140
- /** "1 Kind", "2 Kinder": a count in its plural form. */
141
- const counted = (forms, n, locale) => (forms[new Intl.PluralRules(locale).select(n)] ?? forms.other).replace("{n}", String(n));
143
+ /** "14.–21. Juli", "26. Dez.–2. Jan.": the short form, without years even over New Year. */
144
+ const shortPeriod = (stay, locale) => {
145
+ const short = new Intl.DateTimeFormat(dateLocale(locale), {
146
+ day: "numeric",
147
+ month: "short",
148
+ timeZone: "UTC"
149
+ });
150
+ return stay.start.slice(0, 4) === stay.end.slice(0, 4) ? short.formatRange(day(stay.start), day(stay.end)) : `${short.format(day(stay.start))}–${short.format(day(stay.end))}`;
151
+ };
152
+ /** "1 Kind", "2 Kinder": a count in its plural form, its number and noun joined by a no-break space,
153
+ * so a narrow card never ends a line on the "2". */
154
+ const counted = (forms, n, locale) => (forms[new Intl.PluralRules(locale).select(n)] ?? forms.other).replace("{n} ", `${n}\u00a0`).replace("{n}", String(n));
155
+ /** "2 Schlafzimmer" from the PMS, its number and noun joined by a no-break space as in `counted`. */
156
+ const keepCount = (fact) => fact.replace(/^(\d+) /, "$1\xA0");
142
157
  /** A review count as the PMS formats it ("36", or "1 Bewertungen" on v10), as the number it states. */
143
158
  const reviewCount = (count) => Number(count.replace(/\D/g, ""));
144
159
  const partyText = (stay, copy, locale) => {
145
160
  const children = stay.party.children + stay.party.babies;
146
- return [counted(copy.adults, stay.party.adults, locale), ...children ? [counted(copy.children, children, locale)] : []].join(", ");
161
+ return [
162
+ counted(copy.adults, stay.party.adults, locale),
163
+ ...children ? [counted(copy.children, children, locale)] : [],
164
+ ...stay.party.pets ? [counted(copy.pets, stay.party.pets, locale)] : []
165
+ ].join(", ");
147
166
  };
148
167
  /** A bookable stay goes to the rental's booking page with everything chosen, where the site has one;
149
168
  * otherwise to the rental page (or the search) with the stay filled in. */
@@ -168,12 +187,25 @@ const ReplyLink = ({ href, className, children }) => {
168
187
  children
169
188
  });
170
189
  };
190
+ /** What a booking link is for, for a screen reader: in a links list, eight "Buchen" say nothing. */
191
+ const LinkTarget = ({ rentalId, stay }) => {
192
+ const { locale } = useChat();
193
+ const rental = useRental(rentalId);
194
+ if (!rental) return null;
195
+ return /* @__PURE__ */ jsx("span", {
196
+ className: "sr-only",
197
+ children: `, ${rental.name}${stay?.bookable ? `, ${shortPeriod(stay, locale)}` : ""}`
198
+ });
199
+ };
171
200
  const BookLink = ({ rentalId, stay }) => {
172
201
  const { copy } = useChat();
173
- return /* @__PURE__ */ jsx(ReplyLink, {
202
+ return /* @__PURE__ */ jsxs(ReplyLink, {
174
203
  className: "button",
175
204
  href: useBookHref(rentalId, stay),
176
- children: stay?.bookable ? copy.bookStay : copy.viewRental
205
+ children: [stay?.bookable ? copy.bookStay : copy.viewRental, /* @__PURE__ */ jsx(LinkTarget, {
206
+ rentalId,
207
+ stay
208
+ })]
177
209
  });
178
210
  };
179
211
  /** A row that snaps item by item, with arrow buttons and "n / total"; swipe, scroll or keys move it. */
@@ -214,7 +246,7 @@ const Snapper = ({ count, className, label, children }) => {
214
246
  "aria-label": copy.previous,
215
247
  disabled: index === 0,
216
248
  onClick: () => go(-1),
217
- children: "‹"
249
+ children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
218
250
  }),
219
251
  /* @__PURE__ */ jsxs("span", {
220
252
  className: "muted",
@@ -229,7 +261,7 @@ const Snapper = ({ count, className, label, children }) => {
229
261
  "aria-label": copy.next,
230
262
  disabled: index >= count - 1,
231
263
  onClick: () => go(1),
232
- children: "›"
264
+ children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
233
265
  })
234
266
  ]
235
267
  })]
@@ -295,7 +327,6 @@ const Gallery = ({ images, name }) => {
295
327
  }),
296
328
  /* @__PURE__ */ jsx("span", {
297
329
  className: "sr-only",
298
- "aria-live": "polite",
299
330
  children: `${copy.photo} ${Math.min(index + 1, images.length)} / ${images.length}`
300
331
  })
301
332
  ] })]
@@ -325,17 +356,21 @@ const Wishes = ({ wishes }) => {
325
356
  children: wishes.map((wish) => /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx(Check, {}), wish] }, wish))
326
357
  });
327
358
  };
328
- /** "7 Nächte · 26.12.–2.1.": a stay's length and dates, short. */
359
+ /** "7 Nächte · 6.–13. Mai": a stay's length and dates, short. */
329
360
  const stayMeta = (stay, copy, locale) => {
330
361
  const nights = Math.round((day(stay.end).getTime() - day(stay.start).getTime()) / 864e5);
331
- const short = new Intl.DateTimeFormat(locale, {
332
- day: "numeric",
333
- month: "numeric",
334
- timeZone: "UTC"
335
- });
336
- return `${counted(copy.nights, nights, locale)} · ${short.format(day(stay.start))}–${short.format(day(stay.end))}`;
362
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
363
+ counted(copy.nights, nights, locale),
364
+ " ·",
365
+ " ",
366
+ /* @__PURE__ */ jsx("span", {
367
+ className: "period",
368
+ children: shortPeriod(stay, locale)
369
+ })
370
+ ] });
337
371
  };
338
- /** A card's foot: the stay's total and its length, and the way to book or to the rental. */
372
+ /** A card's foot: the stay's total, its length and dates and the party it is for, and the way to
373
+ * book or to the rental. */
339
374
  const CardFoot = ({ rentalId, stay }) => {
340
375
  const { copy, locale } = useChat();
341
376
  const href = useBookHref(rentalId, stay);
@@ -344,19 +379,29 @@ const CardFoot = ({ rentalId, stay }) => {
344
379
  children: [stay && /* @__PURE__ */ jsx("div", {
345
380
  className: "foot-price",
346
381
  children: !stay.bookable ? /* @__PURE__ */ jsx("span", {
347
- className: "muted",
348
- children: copy.notBookable
349
- }) : /* @__PURE__ */ jsxs(Fragment, { children: [stay.total && /* @__PURE__ */ jsx("span", {
350
- className: "price",
351
- children: stay.total
352
- }), /* @__PURE__ */ jsx("span", {
353
382
  className: "foot-meta",
354
- children: stayMeta(stay, copy, locale)
355
- })] })
356
- }), /* @__PURE__ */ jsx(ReplyLink, {
383
+ children: copy.notBookable
384
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [
385
+ stay.total && /* @__PURE__ */ jsx("span", {
386
+ className: "price",
387
+ children: stay.total
388
+ }),
389
+ /* @__PURE__ */ jsx("span", {
390
+ className: "foot-meta",
391
+ children: stayMeta(stay, copy, locale)
392
+ }),
393
+ /* @__PURE__ */ jsx("span", {
394
+ className: "foot-meta",
395
+ children: partyText(stay, copy, locale)
396
+ })
397
+ ] })
398
+ }), /* @__PURE__ */ jsxs(ReplyLink, {
357
399
  className: "button compact",
358
400
  href,
359
- children: stay?.bookable ? copy.book : copy.view
401
+ children: [stay?.bookable ? copy.book : copy.view, /* @__PURE__ */ jsx(LinkTarget, {
402
+ rentalId,
403
+ stay
404
+ })]
360
405
  })]
361
406
  });
362
407
  };
@@ -392,7 +437,7 @@ const Card = ({ rentalId, stayId }) => {
392
437
  }),
393
438
  rental.highlights.length > 0 && /* @__PURE__ */ jsx("p", {
394
439
  className: "specs",
395
- children: rental.highlights.slice(0, 3).join(" · ")
440
+ children: rental.highlights.slice(0, 3).map(keepCount).join(" · ")
396
441
  }),
397
442
  /* @__PURE__ */ jsx(Wishes, { wishes })
398
443
  ]
@@ -430,17 +475,17 @@ const Calendar = ({ rentalId, stayId }) => {
430
475
  const [shown, setShown] = useState(() => Math.max(0, stay ? months.indexOf(stay.start.slice(0, 7)) : 0));
431
476
  if (!calendar || months.length === 0) return null;
432
477
  const month = months[Math.min(shown, months.length - 1)];
433
- const weekdays = Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(locale, {
478
+ const weekdays = Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(dateLocale(locale), {
434
479
  weekday: "short",
435
480
  timeZone: "UTC"
436
481
  }).format(day(`2024-01-0${i + 1}`)));
437
- const title = new Intl.DateTimeFormat(locale, {
482
+ const title = new Intl.DateTimeFormat(dateLocale(locale), {
438
483
  month: "long",
439
484
  year: "numeric",
440
485
  timeZone: "UTC"
441
486
  }).format(day(`${month}-01`));
442
487
  const inStay = (date) => Boolean(stay && date >= stay.start && date <= stay.end);
443
- const fullDate = new Intl.DateTimeFormat(locale, {
488
+ const fullDate = new Intl.DateTimeFormat(dateLocale(locale), {
444
489
  weekday: "long",
445
490
  day: "numeric",
446
491
  month: "long",
@@ -462,7 +507,7 @@ const Calendar = ({ rentalId, stayId }) => {
462
507
  "aria-label": copy.previous,
463
508
  disabled: shown === 0,
464
509
  onClick: () => setShown(shown - 1),
465
- children: "‹"
510
+ children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
466
511
  }),
467
512
  /* @__PURE__ */ jsxs("span", { children: [title, rental ? ` · ${rental.name}` : ""] }),
468
513
  /* @__PURE__ */ jsx("button", {
@@ -470,7 +515,7 @@ const Calendar = ({ rentalId, stayId }) => {
470
515
  "aria-label": copy.next,
471
516
  disabled: shown >= months.length - 1,
472
517
  onClick: () => setShown(shown + 1),
473
- children: "›"
518
+ children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
474
519
  })
475
520
  ]
476
521
  }),
@@ -517,6 +562,8 @@ const CompareColumn = ({ rentalId, stayId }) => {
517
562
  const stay = useStay(stayId, rentalId);
518
563
  const href = useBookHref(rentalId, stay);
519
564
  if (!rental) return null;
565
+ const rooms = rental.rooms.slice(0, 3);
566
+ const highlights = rental.highlights.filter((highlight) => !rooms.includes(highlight));
520
567
  return /* @__PURE__ */ jsxs("div", {
521
568
  className: "compare-col",
522
569
  children: [
@@ -525,12 +572,17 @@ const CompareColumn = ({ rentalId, stayId }) => {
525
572
  /* @__PURE__ */ jsx("dt", { children: copy.compareRating }),
526
573
  /* @__PURE__ */ jsx("dd", { children: rental.rating ? `★ ${rental.rating.value} (${counted(copy.reviews, reviewCount(rental.rating.count), locale)})` : "–" }),
527
574
  /* @__PURE__ */ jsx("dt", { children: copy.compareRooms }),
528
- /* @__PURE__ */ jsx("dd", { children: rental.rooms.slice(0, 3).join(", ") || "–" }),
575
+ /* @__PURE__ */ jsx("dd", { children: rooms.map(keepCount).join(", ") || "–" }),
529
576
  /* @__PURE__ */ jsx("dt", { children: copy.compareHighlights }),
530
- /* @__PURE__ */ jsx("dd", { children: rental.highlights.slice(0, 4).join(", ") || "–" }),
577
+ /* @__PURE__ */ jsx("dd", { children: highlights.slice(0, 4).map(keepCount).join(", ") || "–" }),
531
578
  stay && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("dt", { children: copy.compareStay }), /* @__PURE__ */ jsxs("dd", { children: [
532
579
  shortPeriod(stay, locale),
533
580
  /* @__PURE__ */ jsx("br", {}),
581
+ /* @__PURE__ */ jsx("span", {
582
+ className: "muted",
583
+ children: partyText(stay, copy, locale)
584
+ }),
585
+ /* @__PURE__ */ jsx("br", {}),
534
586
  !stay.bookable ? /* @__PURE__ */ jsx("span", {
535
587
  className: "muted",
536
588
  children: copy.notBookable
@@ -540,23 +592,24 @@ const CompareColumn = ({ rentalId, stayId }) => {
540
592
  })
541
593
  ] })] })
542
594
  ] }),
543
- /* @__PURE__ */ jsx(ReplyLink, {
595
+ /* @__PURE__ */ jsxs(ReplyLink, {
544
596
  className: "button",
545
597
  href,
546
- children: stay?.bookable ? copy.bookStay : copy.viewRental
598
+ children: [stay?.bookable ? copy.book : copy.view, /* @__PURE__ */ jsx(LinkTarget, {
599
+ rentalId,
600
+ stay
601
+ })]
547
602
  })
548
603
  ]
549
604
  });
550
605
  };
551
- const AlternativeChip = ({ rentalId, stayId }) => {
606
+ const AlternativeChip = ({ stay }) => {
552
607
  const { locale } = useChat();
553
- const stay = useStay(stayId, rentalId);
554
- const href = useBookHref(rentalId, stay);
555
- if (!stay?.bookable) return null;
556
- return /* @__PURE__ */ jsxs(ReplyLink, {
608
+ const href = useBookHref(stay.rentalId, stay);
609
+ return /* @__PURE__ */ jsx(ReplyLink, {
557
610
  className: "chip-link",
558
611
  href,
559
- children: [/* @__PURE__ */ jsx("strong", { children: shortPeriod(stay, locale) }), stay.total ? ` · ${stay.total}` : ""]
612
+ children: /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("strong", { children: shortPeriod(stay, locale) }), stay.total ? ` · ${stay.total}` : ""] })
560
613
  });
561
614
  };
562
615
  /** One arrival of a price calendar: its bar, the PMS's total, the day; tapping asks for that stay. */
@@ -565,10 +618,8 @@ const PriceColumn = ({ rentalId, option, asked, cheapest }) => {
565
618
  const rental = useRental(rentalId);
566
619
  const stay = useStay(option.stayId, rentalId);
567
620
  const free = Boolean(stay?.bookable && stay.total);
568
- const arrival = new Intl.DateTimeFormat(locale, {
569
- weekday: "short",
570
- day: "numeric",
571
- month: "numeric",
621
+ const arrival = (options) => new Intl.DateTimeFormat(dateLocale(locale), {
622
+ ...options,
572
623
  timeZone: "UTC"
573
624
  }).format(day(option.start));
574
625
  const dates = period(option, locale);
@@ -600,18 +651,45 @@ const PriceColumn = ({ rentalId, option, asked, cheapest }) => {
600
651
  "aria-hidden": "true",
601
652
  children: free ? stay.total : copy.closed
602
653
  }),
603
- /* @__PURE__ */ jsx("span", {
654
+ /* @__PURE__ */ jsxs("span", {
604
655
  className: "muted",
605
656
  "aria-hidden": "true",
606
- children: arrival
657
+ children: [
658
+ arrival({ weekday: "short" }),
659
+ /* @__PURE__ */ jsx("br", {}),
660
+ arrival({
661
+ day: "numeric",
662
+ month: "short"
663
+ })
664
+ ]
607
665
  })
608
666
  ]
609
667
  }) });
610
668
  };
669
+ /** Which ends of a scrolled row have more beyond them, for the fade there. */
670
+ const hiddenEdges = (row) => {
671
+ const start = row.scrollLeft > 1;
672
+ const end = row.scrollLeft + row.clientWidth < row.scrollWidth - 1;
673
+ return start && end ? "both" : start ? "start" : end ? "end" : void 0;
674
+ };
611
675
  const PriceCalendarView = ({ rentalId }) => {
612
676
  const { copy, locale } = useChat();
613
677
  const rental = useRental(rentalId);
614
678
  const calendar = useStateValue(statePath("priceCalendars", rentalId)) ?? null;
679
+ const row = useRef(null);
680
+ const [fade, setFade] = useState();
681
+ useEffect(() => {
682
+ const element = row.current;
683
+ if (!element || !calendar) return;
684
+ const cheapest = calendar.options.findIndex((option) => option.stayId !== null && option.stayId === calendar.cheapest);
685
+ const asked = calendar.options.findIndex((option) => option.start === calendar.asked);
686
+ const column = element.children[cheapest >= 0 ? cheapest : asked];
687
+ if (column) {
688
+ const end = column.offsetLeft + column.offsetWidth;
689
+ if (end > element.clientWidth) element.scrollLeft = end - element.clientWidth;
690
+ }
691
+ setFade(hiddenEdges(element));
692
+ }, [calendar]);
615
693
  if (!calendar || calendar.options.length === 0) return null;
616
694
  return /* @__PURE__ */ jsx("section", {
617
695
  className: "card price-calendar",
@@ -628,7 +706,10 @@ const PriceCalendarView = ({ rentalId }) => {
628
706
  children: [counted(copy.priceCalendarNights, calendar.nights, locale), rental ? ` · ${rental.name}` : ""]
629
707
  }),
630
708
  /* @__PURE__ */ jsx("ul", {
709
+ ref: row,
631
710
  className: "price-columns",
711
+ "data-fade": fade,
712
+ onScroll: (event) => setFade(hiddenEdges(event.currentTarget)),
632
713
  children: calendar.options.map((option) => /* @__PURE__ */ jsx(PriceColumn, {
633
714
  rentalId,
634
715
  option,
@@ -749,7 +830,7 @@ const { registry } = defineRegistry(chatCatalog, { components: {
749
830
  return /* @__PURE__ */ jsx(Snapper, {
750
831
  count: props.items.length,
751
832
  className: "carousel",
752
- label: copy.title,
833
+ label: copy.rentalsFound,
753
834
  children: props.items.map((item) => /* @__PURE__ */ jsx(Card, {
754
835
  rentalId: item.rentalId,
755
836
  stayId: item.stayId
@@ -757,21 +838,17 @@ const { registry } = defineRegistry(chatCatalog, { components: {
757
838
  });
758
839
  },
759
840
  StayQuote: ({ props }) => {
760
- const { copy, locale } = useChat();
761
841
  const stay = useStay(props.stayId);
762
842
  const rental = useRental(stay?.rentalId ?? "");
763
843
  if (!stay) return null;
764
844
  return /* @__PURE__ */ jsxs("article", {
765
845
  className: "card stay-card",
766
- children: [/* @__PURE__ */ jsxs("div", {
846
+ children: [/* @__PURE__ */ jsx("div", {
767
847
  className: "card-body",
768
- children: [rental && /* @__PURE__ */ jsx("h3", {
848
+ children: rental && /* @__PURE__ */ jsx("h3", {
769
849
  className: "card-title",
770
850
  children: rental.name
771
- }), /* @__PURE__ */ jsx("p", {
772
- className: "specs",
773
- children: partyText(stay, copy, locale)
774
- })]
851
+ })
775
852
  }), /* @__PURE__ */ jsx(CardFoot, {
776
853
  rentalId: stay.rentalId,
777
854
  stay
@@ -779,18 +856,28 @@ const { registry } = defineRegistry(chatCatalog, { components: {
779
856
  });
780
857
  },
781
858
  PriceBreakdown: ({ props }) => {
782
- const { copy } = useChat();
859
+ const { copy, locale } = useChat();
783
860
  const stay = useStay(props.stayId);
861
+ const rental = useRental(stay?.rentalId ?? "");
784
862
  if (!stay?.bookable || !stay.total) return null;
863
+ const label = rental ? `${copy.priceBreakdown} · ${rental.name}` : copy.priceBreakdown;
785
864
  return /* @__PURE__ */ jsx("section", {
786
865
  className: "card breakdown",
787
- "aria-label": copy.priceBreakdown,
866
+ "aria-label": label,
788
867
  children: /* @__PURE__ */ jsxs("div", {
789
868
  className: "card-body",
790
869
  children: [
791
870
  /* @__PURE__ */ jsx("span", {
792
871
  className: "muted",
793
- children: copy.priceBreakdown
872
+ children: label
873
+ }),
874
+ /* @__PURE__ */ jsxs("span", {
875
+ className: "foot-meta",
876
+ children: [
877
+ stayMeta(stay, copy, locale),
878
+ " · ",
879
+ partyText(stay, copy, locale)
880
+ ]
794
881
  }),
795
882
  stay.lines.map((line) => /* @__PURE__ */ jsxs("div", {
796
883
  className: "line",
@@ -806,14 +893,26 @@ const { registry } = defineRegistry(chatCatalog, { components: {
806
893
  },
807
894
  AlternativeStays: ({ props }) => {
808
895
  const { copy } = useChat();
809
- return /* @__PURE__ */ jsx("div", {
896
+ const rental = useRental(props.rentalId);
897
+ const stays = useStateValue("/stays") ?? {};
898
+ const bookable = props.stayIds.flatMap((stayId) => {
899
+ const stay = stays[stayId];
900
+ return stay?.rentalId === props.rentalId && stay.bookable ? [stay] : [];
901
+ });
902
+ if (bookable.length === 0) return null;
903
+ const label = rental ? `${copy.otherDates} · ${rental.name}` : copy.otherDates;
904
+ return /* @__PURE__ */ jsxs("div", {
810
905
  className: "alternatives",
811
906
  role: "group",
812
- "aria-label": copy.otherDates,
813
- children: props.stayIds.map((stayId) => /* @__PURE__ */ jsx(AlternativeChip, {
814
- rentalId: props.rentalId,
815
- stayId
816
- }, stayId))
907
+ "aria-label": label,
908
+ children: [/* @__PURE__ */ jsx("p", {
909
+ className: "alt-caption",
910
+ "aria-hidden": "true",
911
+ children: label
912
+ }), /* @__PURE__ */ jsx("div", {
913
+ className: "alt-chips",
914
+ children: bookable.map((stay) => /* @__PURE__ */ jsx(AlternativeChip, { stay }, stay.id))
915
+ })]
817
916
  });
818
917
  },
819
918
  AvailabilityCalendar: ({ props }) => /* @__PURE__ */ jsx(Calendar, {
@@ -854,12 +953,12 @@ const { registry } = defineRegistry(chatCatalog, { components: {
854
953
  if (!send) return null;
855
954
  return /* @__PURE__ */ jsx("div", {
856
955
  className: "quick-replies",
857
- children: props.options.map((option) => /* @__PURE__ */ jsxs("button", {
956
+ children: props.options.map((option) => /* @__PURE__ */ jsx("button", {
858
957
  type: "button",
859
958
  className: "chip",
860
959
  disabled: busy,
861
960
  onClick: () => send(option),
862
- children: [/* @__PURE__ */ jsx(TopicIcon, { text: option }), option]
961
+ children: option
863
962
  }, option))
864
963
  });
865
964
  }
@@ -945,19 +1044,45 @@ const Consent = ({ copy, pending, onAccept, buttonRef }) => /* @__PURE__ */ jsxs
945
1044
  })
946
1045
  ]
947
1046
  });
1047
+ /** Shown until the reply's text: a search takes seconds, and three dots alone read as nothing.
1048
+ * Hidden from a screen reader: inside the busy log it may go unannounced, so the panel's own status,
1049
+ * outside the log, says it. */
948
1050
  const Thinking = ({ label }) => /* @__PURE__ */ jsxs("div", {
949
1051
  className: "thinking",
950
- role: "status",
1052
+ "aria-hidden": "true",
951
1053
  children: [
952
1054
  /* @__PURE__ */ jsx("i", {}),
953
1055
  /* @__PURE__ */ jsx("i", {}),
954
1056
  /* @__PURE__ */ jsx("i", {}),
955
1057
  /* @__PURE__ */ jsx("span", {
956
- className: "sr-only",
1058
+ className: "thinking-label",
957
1059
  children: label
958
1060
  })
959
1061
  ]
960
1062
  });
1063
+ /** Why the chat cannot answer, as its reply to the visitor's message, and where to go instead. */
1064
+ const Fallback = ({ reason, copy, searchPath, contactPath }) => /* @__PURE__ */ jsxs("div", {
1065
+ className: "fallback",
1066
+ role: "status",
1067
+ children: [/* @__PURE__ */ jsx("p", {
1068
+ className: "message",
1069
+ "data-role": "assistant",
1070
+ children: reason === "busy" ? copy.busy : copy.unavailable
1071
+ }), /* @__PURE__ */ jsxs("div", {
1072
+ className: "fallback-links",
1073
+ children: [/* @__PURE__ */ jsx("a", {
1074
+ className: "button",
1075
+ href: searchPath,
1076
+ children: copy.searchLink
1077
+ }), /* @__PURE__ */ jsx("a", {
1078
+ className: "link",
1079
+ href: contactPath,
1080
+ children: copy.contactLink
1081
+ })]
1082
+ })]
1083
+ });
1084
+ /** A refusal that a second try can get past: the message goes back into the field to send again. */
1085
+ const RETRYABLE = /* @__PURE__ */ new Set(["busy", "error"]);
961
1086
  function Panel(props) {
962
1087
  const { copy, endpoint, open, pending, onSent, onStarted, onClose } = props;
963
1088
  const stored = useMemo(() => loadConversation(endpoint), [endpoint]);
@@ -968,19 +1093,39 @@ function Panel(props) {
968
1093
  const [unavailable, setUnavailable] = useState(null);
969
1094
  const session = useRef(stored?.sessionId ?? null);
970
1095
  const abort = useRef(null);
971
- const end = useRef(null);
1096
+ const list = useRef(null);
1097
+ const following = useRef(true);
1098
+ const lastRole = useRef(stored?.messages.at(-1)?.role);
972
1099
  const start = useRef(null);
973
1100
  const composer = useRef(null);
974
1101
  const panel = useRef(null);
975
1102
  const lastActivity = useRef(stored?.savedAt ?? Date.now());
976
1103
  const hasMessages = messages.length > 0;
1104
+ const last = messages.at(-1);
1105
+ const waiting = streaming && !unavailable && (last?.role === "visitor" || !last?.parts.some((part) => part.kind === "text"));
977
1106
  const expired = () => Date.now() - lastActivity.current >= SESSION_TTL_S * 1e3;
978
1107
  useEffect(() => () => abort.current?.abort(), []);
979
1108
  useEffect(() => {
980
- if (open) end.current?.scrollIntoView({ block: "end" });
981
- }, [messages, open]);
1109
+ const role = messages.at(-1)?.role;
1110
+ const replyStarts = role === "assistant" && lastRole.current === "visitor";
1111
+ lastRole.current = role;
1112
+ const element = list.current;
1113
+ if (!open || !element) return;
1114
+ const turnTop = ([...element.querySelectorAll(":scope > .turn")].at(-1)?.offsetTop ?? 0) - 12;
1115
+ if (replyStarts) element.scrollTop = turnTop;
1116
+ else if (role === "assistant" && following.current) element.scrollTop = Math.max(element.scrollTop, Math.min(element.scrollHeight - element.clientHeight, turnTop));
1117
+ else if (role === "visitor" || following.current) element.scrollTop = element.scrollHeight;
1118
+ }, [
1119
+ messages,
1120
+ open,
1121
+ waiting,
1122
+ unavailable
1123
+ ]);
982
1124
  useEffect(() => {
983
- if (open) (accepted ? composer : start).current?.focus();
1125
+ if (!open) return;
1126
+ const touch = window.matchMedia?.("(pointer: coarse)").matches;
1127
+ if (accepted && hasMessages && touch) panel.current?.focus();
1128
+ else (accepted ? composer : start).current?.focus();
984
1129
  }, [accepted, open]);
985
1130
  useEffect(() => {
986
1131
  onStarted(hasMessages);
@@ -1006,16 +1151,21 @@ function Panel(props) {
1006
1151
  clearConversation(endpoint);
1007
1152
  }
1008
1153
  setDraft("");
1154
+ const restoreDraft = () => setDraft((current) => current || message);
1009
1155
  setUnavailable(null);
1010
1156
  setStreaming(true);
1011
- setMessages((current) => [...fresh ? [] : current, {
1012
- role: "visitor",
1013
- parts: [{
1014
- kind: "text",
1015
- text: message
1016
- }],
1017
- unconfirmed: true
1018
- }]);
1157
+ setMessages((current) => {
1158
+ const before = fresh ? [] : current;
1159
+ const last = before.at(-1);
1160
+ return [...last?.role === "visitor" && last.unconfirmed && last.parts[0]?.kind === "text" && last.parts[0].text === message ? before.slice(0, -1) : before, {
1161
+ role: "visitor",
1162
+ parts: [{
1163
+ kind: "text",
1164
+ text: message
1165
+ }],
1166
+ unconfirmed: true
1167
+ }];
1168
+ });
1019
1169
  const sentAt = Date.now();
1020
1170
  const controller = new AbortController();
1021
1171
  abort.current = controller;
@@ -1036,14 +1186,19 @@ function Panel(props) {
1036
1186
  kind: "ui",
1037
1187
  spec: chatEvent.spec
1038
1188
  }));
1039
- else if (chatEvent.type === "unavailable") setUnavailable(chatEvent.reason);
1040
- else if (chatEvent.type === "done") {
1189
+ else if (chatEvent.type === "unavailable") {
1190
+ setUnavailable(chatEvent.reason);
1191
+ if (RETRYABLE.has(chatEvent.reason)) restoreDraft();
1192
+ } else if (chatEvent.type === "done") {
1041
1193
  lastActivity.current = sentAt;
1042
1194
  setMessages(confirmed);
1043
1195
  }
1044
1196
  }, controller.signal);
1045
1197
  } catch {
1046
- if (!controller.signal.aborted) setUnavailable("error");
1198
+ if (!controller.signal.aborted) {
1199
+ setUnavailable("error");
1200
+ restoreDraft();
1201
+ }
1047
1202
  } finally {
1048
1203
  if (abort.current === controller) setStreaming(false);
1049
1204
  }
@@ -1088,7 +1243,8 @@ function Panel(props) {
1088
1243
  const focusable = [...element.querySelectorAll("a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled])")];
1089
1244
  const active = element.getRootNode().activeElement;
1090
1245
  const edge = event.shiftKey ? focusable[0] : focusable.at(-1);
1091
- if (focusable.length === 0 || active === edge || !element.contains(active)) {
1246
+ const outside = !element.contains(active) || event.shiftKey && active === element;
1247
+ if (focusable.length === 0 || active === edge || outside) {
1092
1248
  event.preventDefault();
1093
1249
  (event.shiftKey ? focusable.at(-1) : focusable[0])?.focus();
1094
1250
  }
@@ -1097,7 +1253,6 @@ function Panel(props) {
1097
1253
  event.preventDefault();
1098
1254
  submit(draft);
1099
1255
  };
1100
- const waiting = streaming && messages.at(-1)?.role === "visitor";
1101
1256
  return /* @__PURE__ */ jsxs(ChatContext.Provider, {
1102
1257
  value: {
1103
1258
  ...props,
@@ -1115,6 +1270,7 @@ function Panel(props) {
1115
1270
  "aria-modal": "true",
1116
1271
  "aria-label": copy.title,
1117
1272
  ref: panel,
1273
+ tabIndex: -1,
1118
1274
  hidden: !open,
1119
1275
  inert: !open,
1120
1276
  onKeyDown: (event) => {
@@ -1150,50 +1306,50 @@ function Panel(props) {
1150
1306
  className: "notice",
1151
1307
  children: copy.notice
1152
1308
  }),
1153
- accepted ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
1154
- className: "messages",
1155
- "aria-live": "polite",
1156
- children: [
1157
- /* @__PURE__ */ jsx("div", {
1158
- className: "message",
1159
- "data-role": "assistant",
1160
- children: copy.greeting
1161
- }),
1162
- messages.map((message, index) => /* @__PURE__ */ jsx("div", {
1163
- className: message.role === "assistant" ? "ui" : "turn",
1164
- children: message.parts.map((part, partIndex) => part.kind === "text" ? /* @__PURE__ */ jsx("div", {
1309
+ accepted ? /* @__PURE__ */ jsxs(Fragment, { children: [
1310
+ /* @__PURE__ */ jsxs("div", {
1311
+ className: "messages",
1312
+ role: "log",
1313
+ "aria-busy": streaming,
1314
+ ref: list,
1315
+ onScroll: (event) => {
1316
+ const element = event.currentTarget;
1317
+ following.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
1318
+ },
1319
+ children: [
1320
+ /* @__PURE__ */ jsx("div", {
1165
1321
  className: "message",
1166
- "data-role": message.role,
1167
- children: message.role === "assistant" ? /* @__PURE__ */ jsx(RichText, { text: part.text }) : part.text
1168
- }, partIndex) : /* @__PURE__ */ jsx(SpecView, { spec: part.spec }, partIndex))
1169
- }, index)),
1170
- waiting && /* @__PURE__ */ jsx(Thinking, { label: copy.thinking }),
1171
- /* @__PURE__ */ jsx("div", { ref: end })
1172
- ]
1173
- }), /* @__PURE__ */ jsxs("div", {
1174
- className: "dock",
1175
- children: [
1176
- unavailable && /* @__PURE__ */ jsxs("div", {
1177
- className: "fallback",
1178
- role: "status",
1179
- children: [
1180
- /* @__PURE__ */ jsx("p", {
1181
- className: "muted",
1182
- children: unavailable === "busy" ? copy.busy : copy.unavailable
1183
- }),
1184
- /* @__PURE__ */ jsx("a", {
1185
- className: "link",
1186
- href: props.searchPath,
1187
- children: copy.searchLink
1188
- }),
1189
- /* @__PURE__ */ jsx("a", {
1190
- className: "link",
1191
- href: props.contactPath,
1192
- children: copy.contactLink
1193
- })
1194
- ]
1195
- }),
1196
- !hasMessages && copy.suggestions.length > 0 && /* @__PURE__ */ jsx("div", {
1322
+ "data-role": "assistant",
1323
+ children: copy.greeting
1324
+ }),
1325
+ messages.map((message, index) => /* @__PURE__ */ jsxs("div", {
1326
+ className: message.role === "assistant" ? "ui" : "turn",
1327
+ children: [/* @__PURE__ */ jsxs("span", {
1328
+ className: "sr-only",
1329
+ children: [message.role === "assistant" ? copy.title : copy.yourMessage, ":"]
1330
+ }), message.parts.map((part, partIndex) => part.kind === "text" ? /* @__PURE__ */ jsx("div", {
1331
+ className: "message",
1332
+ "data-role": message.role,
1333
+ children: message.role === "assistant" ? /* @__PURE__ */ jsx(RichText, { text: part.text }) : part.text
1334
+ }, partIndex) : /* @__PURE__ */ jsx(SpecView, { spec: part.spec }, partIndex))]
1335
+ }, index)),
1336
+ unavailable && /* @__PURE__ */ jsx(Fallback, {
1337
+ reason: unavailable,
1338
+ copy,
1339
+ searchPath: props.searchPath,
1340
+ contactPath: props.contactPath
1341
+ }),
1342
+ waiting && /* @__PURE__ */ jsx(Thinking, { label: copy.thinking })
1343
+ ]
1344
+ }),
1345
+ /* @__PURE__ */ jsx("p", {
1346
+ className: "sr-only",
1347
+ role: "status",
1348
+ children: waiting ? copy.thinking : ""
1349
+ }),
1350
+ /* @__PURE__ */ jsxs("div", {
1351
+ className: "dock",
1352
+ children: [!hasMessages && copy.suggestions.length > 0 && /* @__PURE__ */ jsx("div", {
1197
1353
  className: "suggestions",
1198
1354
  children: copy.suggestions.map((suggestion) => /* @__PURE__ */ jsxs("button", {
1199
1355
  type: "button",
@@ -1201,8 +1357,7 @@ function Panel(props) {
1201
1357
  onClick: () => void submit(suggestion),
1202
1358
  children: [/* @__PURE__ */ jsx(TopicIcon, { text: suggestion }), suggestion]
1203
1359
  }, suggestion))
1204
- }),
1205
- /* @__PURE__ */ jsxs("form", {
1360
+ }), /* @__PURE__ */ jsxs("form", {
1206
1361
  className: "composer",
1207
1362
  onSubmit: send,
1208
1363
  children: [/* @__PURE__ */ jsx("textarea", {
@@ -1225,9 +1380,9 @@ function Panel(props) {
1225
1380
  disabled: streaming || !draft.trim(),
1226
1381
  children: /* @__PURE__ */ jsx(Arrow, {})
1227
1382
  })]
1228
- })
1229
- ]
1230
- })] }) : /* @__PURE__ */ jsx(Consent, {
1383
+ })]
1384
+ })
1385
+ ] }) : /* @__PURE__ */ jsx(Consent, {
1231
1386
  copy,
1232
1387
  pending,
1233
1388
  onAccept: () => setAccepted(true),