@v-office/website-chat 1.1.3 → 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";
@@ -130,26 +130,39 @@ const useStay = (stayId, rentalId) => {
130
130
  };
131
131
  const useRental = (rentalId) => useStateValue(statePath("rentals", rentalId)) ?? null;
132
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";
133
136
  /** "12.–19. Juli 2027" from two `YYYY-MM-DD` days. */
134
- const period = (stay, locale) => new Intl.DateTimeFormat(locale, {
137
+ const period = (stay, locale) => new Intl.DateTimeFormat(dateLocale(locale), {
135
138
  day: "numeric",
136
139
  month: "short",
137
140
  year: "numeric",
138
141
  timeZone: "UTC"
139
142
  }).formatRange(day(stay.start), day(stay.end));
140
- /** "14.–21. Juli": the short form for choices next to each other. */
141
- const shortPeriod = (stay, locale) => new Intl.DateTimeFormat(locale, {
142
- day: "numeric",
143
- month: "short",
144
- timeZone: "UTC"
145
- }).formatRange(day(stay.start), day(stay.end));
146
- /** "1 Kind", "2 Kinder": a count in its plural form. */
147
- 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");
148
157
  /** A review count as the PMS formats it ("36", or "1 Bewertungen" on v10), as the number it states. */
149
158
  const reviewCount = (count) => Number(count.replace(/\D/g, ""));
150
159
  const partyText = (stay, copy, locale) => {
151
160
  const children = stay.party.children + stay.party.babies;
152
- 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(", ");
153
166
  };
154
167
  /** A bookable stay goes to the rental's booking page with everything chosen, where the site has one;
155
168
  * otherwise to the rental page (or the search) with the stay filled in. */
@@ -174,12 +187,25 @@ const ReplyLink = ({ href, className, children }) => {
174
187
  children
175
188
  });
176
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
+ };
177
200
  const BookLink = ({ rentalId, stay }) => {
178
201
  const { copy } = useChat();
179
- return /* @__PURE__ */ jsx(ReplyLink, {
202
+ return /* @__PURE__ */ jsxs(ReplyLink, {
180
203
  className: "button",
181
204
  href: useBookHref(rentalId, stay),
182
- children: stay?.bookable ? copy.bookStay : copy.viewRental
205
+ children: [stay?.bookable ? copy.bookStay : copy.viewRental, /* @__PURE__ */ jsx(LinkTarget, {
206
+ rentalId,
207
+ stay
208
+ })]
183
209
  });
184
210
  };
185
211
  /** A row that snaps item by item, with arrow buttons and "n / total"; swipe, scroll or keys move it. */
@@ -220,7 +246,7 @@ const Snapper = ({ count, className, label, children }) => {
220
246
  "aria-label": copy.previous,
221
247
  disabled: index === 0,
222
248
  onClick: () => go(-1),
223
- children: "‹"
249
+ children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
224
250
  }),
225
251
  /* @__PURE__ */ jsxs("span", {
226
252
  className: "muted",
@@ -235,7 +261,7 @@ const Snapper = ({ count, className, label, children }) => {
235
261
  "aria-label": copy.next,
236
262
  disabled: index >= count - 1,
237
263
  onClick: () => go(1),
238
- children: "›"
264
+ children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
239
265
  })
240
266
  ]
241
267
  })]
@@ -301,7 +327,6 @@ const Gallery = ({ images, name }) => {
301
327
  }),
302
328
  /* @__PURE__ */ jsx("span", {
303
329
  className: "sr-only",
304
- "aria-live": "polite",
305
330
  children: `${copy.photo} ${Math.min(index + 1, images.length)} / ${images.length}`
306
331
  })
307
332
  ] })]
@@ -331,17 +356,21 @@ const Wishes = ({ wishes }) => {
331
356
  children: wishes.map((wish) => /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx(Check, {}), wish] }, wish))
332
357
  });
333
358
  };
334
- /** "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. */
335
360
  const stayMeta = (stay, copy, locale) => {
336
361
  const nights = Math.round((day(stay.end).getTime() - day(stay.start).getTime()) / 864e5);
337
- const short = new Intl.DateTimeFormat(locale, {
338
- day: "numeric",
339
- month: "numeric",
340
- timeZone: "UTC"
341
- });
342
- 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
+ ] });
343
371
  };
344
- /** 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. */
345
374
  const CardFoot = ({ rentalId, stay }) => {
346
375
  const { copy, locale } = useChat();
347
376
  const href = useBookHref(rentalId, stay);
@@ -350,19 +379,29 @@ const CardFoot = ({ rentalId, stay }) => {
350
379
  children: [stay && /* @__PURE__ */ jsx("div", {
351
380
  className: "foot-price",
352
381
  children: !stay.bookable ? /* @__PURE__ */ jsx("span", {
353
- className: "muted",
354
- children: copy.notBookable
355
- }) : /* @__PURE__ */ jsxs(Fragment, { children: [stay.total && /* @__PURE__ */ jsx("span", {
356
- className: "price",
357
- children: stay.total
358
- }), /* @__PURE__ */ jsx("span", {
359
382
  className: "foot-meta",
360
- children: stayMeta(stay, copy, locale)
361
- })] })
362
- }), /* @__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, {
363
399
  className: "button compact",
364
400
  href,
365
- children: stay?.bookable ? copy.book : copy.view
401
+ children: [stay?.bookable ? copy.book : copy.view, /* @__PURE__ */ jsx(LinkTarget, {
402
+ rentalId,
403
+ stay
404
+ })]
366
405
  })]
367
406
  });
368
407
  };
@@ -398,7 +437,7 @@ const Card = ({ rentalId, stayId }) => {
398
437
  }),
399
438
  rental.highlights.length > 0 && /* @__PURE__ */ jsx("p", {
400
439
  className: "specs",
401
- children: rental.highlights.slice(0, 3).join(" · ")
440
+ children: rental.highlights.slice(0, 3).map(keepCount).join(" · ")
402
441
  }),
403
442
  /* @__PURE__ */ jsx(Wishes, { wishes })
404
443
  ]
@@ -436,17 +475,17 @@ const Calendar = ({ rentalId, stayId }) => {
436
475
  const [shown, setShown] = useState(() => Math.max(0, stay ? months.indexOf(stay.start.slice(0, 7)) : 0));
437
476
  if (!calendar || months.length === 0) return null;
438
477
  const month = months[Math.min(shown, months.length - 1)];
439
- const weekdays = Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(locale, {
478
+ const weekdays = Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(dateLocale(locale), {
440
479
  weekday: "short",
441
480
  timeZone: "UTC"
442
481
  }).format(day(`2024-01-0${i + 1}`)));
443
- const title = new Intl.DateTimeFormat(locale, {
482
+ const title = new Intl.DateTimeFormat(dateLocale(locale), {
444
483
  month: "long",
445
484
  year: "numeric",
446
485
  timeZone: "UTC"
447
486
  }).format(day(`${month}-01`));
448
487
  const inStay = (date) => Boolean(stay && date >= stay.start && date <= stay.end);
449
- const fullDate = new Intl.DateTimeFormat(locale, {
488
+ const fullDate = new Intl.DateTimeFormat(dateLocale(locale), {
450
489
  weekday: "long",
451
490
  day: "numeric",
452
491
  month: "long",
@@ -468,7 +507,7 @@ const Calendar = ({ rentalId, stayId }) => {
468
507
  "aria-label": copy.previous,
469
508
  disabled: shown === 0,
470
509
  onClick: () => setShown(shown - 1),
471
- children: "‹"
510
+ children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
472
511
  }),
473
512
  /* @__PURE__ */ jsxs("span", { children: [title, rental ? ` · ${rental.name}` : ""] }),
474
513
  /* @__PURE__ */ jsx("button", {
@@ -476,7 +515,7 @@ const Calendar = ({ rentalId, stayId }) => {
476
515
  "aria-label": copy.next,
477
516
  disabled: shown >= months.length - 1,
478
517
  onClick: () => setShown(shown + 1),
479
- children: "›"
518
+ children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
480
519
  })
481
520
  ]
482
521
  }),
@@ -523,6 +562,8 @@ const CompareColumn = ({ rentalId, stayId }) => {
523
562
  const stay = useStay(stayId, rentalId);
524
563
  const href = useBookHref(rentalId, stay);
525
564
  if (!rental) return null;
565
+ const rooms = rental.rooms.slice(0, 3);
566
+ const highlights = rental.highlights.filter((highlight) => !rooms.includes(highlight));
526
567
  return /* @__PURE__ */ jsxs("div", {
527
568
  className: "compare-col",
528
569
  children: [
@@ -531,12 +572,17 @@ const CompareColumn = ({ rentalId, stayId }) => {
531
572
  /* @__PURE__ */ jsx("dt", { children: copy.compareRating }),
532
573
  /* @__PURE__ */ jsx("dd", { children: rental.rating ? `★ ${rental.rating.value} (${counted(copy.reviews, reviewCount(rental.rating.count), locale)})` : "–" }),
533
574
  /* @__PURE__ */ jsx("dt", { children: copy.compareRooms }),
534
- /* @__PURE__ */ jsx("dd", { children: rental.rooms.slice(0, 3).join(", ") || "–" }),
575
+ /* @__PURE__ */ jsx("dd", { children: rooms.map(keepCount).join(", ") || "–" }),
535
576
  /* @__PURE__ */ jsx("dt", { children: copy.compareHighlights }),
536
- /* @__PURE__ */ jsx("dd", { children: rental.highlights.slice(0, 4).join(", ") || "–" }),
577
+ /* @__PURE__ */ jsx("dd", { children: highlights.slice(0, 4).map(keepCount).join(", ") || "–" }),
537
578
  stay && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("dt", { children: copy.compareStay }), /* @__PURE__ */ jsxs("dd", { children: [
538
579
  shortPeriod(stay, locale),
539
580
  /* @__PURE__ */ jsx("br", {}),
581
+ /* @__PURE__ */ jsx("span", {
582
+ className: "muted",
583
+ children: partyText(stay, copy, locale)
584
+ }),
585
+ /* @__PURE__ */ jsx("br", {}),
540
586
  !stay.bookable ? /* @__PURE__ */ jsx("span", {
541
587
  className: "muted",
542
588
  children: copy.notBookable
@@ -546,23 +592,24 @@ const CompareColumn = ({ rentalId, stayId }) => {
546
592
  })
547
593
  ] })] })
548
594
  ] }),
549
- /* @__PURE__ */ jsx(ReplyLink, {
595
+ /* @__PURE__ */ jsxs(ReplyLink, {
550
596
  className: "button",
551
597
  href,
552
- children: stay?.bookable ? copy.bookStay : copy.viewRental
598
+ children: [stay?.bookable ? copy.book : copy.view, /* @__PURE__ */ jsx(LinkTarget, {
599
+ rentalId,
600
+ stay
601
+ })]
553
602
  })
554
603
  ]
555
604
  });
556
605
  };
557
- const AlternativeChip = ({ rentalId, stayId }) => {
606
+ const AlternativeChip = ({ stay }) => {
558
607
  const { locale } = useChat();
559
- const stay = useStay(stayId, rentalId);
560
- const href = useBookHref(rentalId, stay);
561
- if (!stay?.bookable) return null;
562
- return /* @__PURE__ */ jsxs(ReplyLink, {
608
+ const href = useBookHref(stay.rentalId, stay);
609
+ return /* @__PURE__ */ jsx(ReplyLink, {
563
610
  className: "chip-link",
564
611
  href,
565
- 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}` : ""] })
566
613
  });
567
614
  };
568
615
  /** One arrival of a price calendar: its bar, the PMS's total, the day; tapping asks for that stay. */
@@ -571,10 +618,8 @@ const PriceColumn = ({ rentalId, option, asked, cheapest }) => {
571
618
  const rental = useRental(rentalId);
572
619
  const stay = useStay(option.stayId, rentalId);
573
620
  const free = Boolean(stay?.bookable && stay.total);
574
- const arrival = new Intl.DateTimeFormat(locale, {
575
- weekday: "short",
576
- day: "numeric",
577
- month: "numeric",
621
+ const arrival = (options) => new Intl.DateTimeFormat(dateLocale(locale), {
622
+ ...options,
578
623
  timeZone: "UTC"
579
624
  }).format(day(option.start));
580
625
  const dates = period(option, locale);
@@ -606,18 +651,45 @@ const PriceColumn = ({ rentalId, option, asked, cheapest }) => {
606
651
  "aria-hidden": "true",
607
652
  children: free ? stay.total : copy.closed
608
653
  }),
609
- /* @__PURE__ */ jsx("span", {
654
+ /* @__PURE__ */ jsxs("span", {
610
655
  className: "muted",
611
656
  "aria-hidden": "true",
612
- children: arrival
657
+ children: [
658
+ arrival({ weekday: "short" }),
659
+ /* @__PURE__ */ jsx("br", {}),
660
+ arrival({
661
+ day: "numeric",
662
+ month: "short"
663
+ })
664
+ ]
613
665
  })
614
666
  ]
615
667
  }) });
616
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
+ };
617
675
  const PriceCalendarView = ({ rentalId }) => {
618
676
  const { copy, locale } = useChat();
619
677
  const rental = useRental(rentalId);
620
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]);
621
693
  if (!calendar || calendar.options.length === 0) return null;
622
694
  return /* @__PURE__ */ jsx("section", {
623
695
  className: "card price-calendar",
@@ -634,7 +706,10 @@ const PriceCalendarView = ({ rentalId }) => {
634
706
  children: [counted(copy.priceCalendarNights, calendar.nights, locale), rental ? ` · ${rental.name}` : ""]
635
707
  }),
636
708
  /* @__PURE__ */ jsx("ul", {
709
+ ref: row,
637
710
  className: "price-columns",
711
+ "data-fade": fade,
712
+ onScroll: (event) => setFade(hiddenEdges(event.currentTarget)),
638
713
  children: calendar.options.map((option) => /* @__PURE__ */ jsx(PriceColumn, {
639
714
  rentalId,
640
715
  option,
@@ -755,7 +830,7 @@ const { registry } = defineRegistry(chatCatalog, { components: {
755
830
  return /* @__PURE__ */ jsx(Snapper, {
756
831
  count: props.items.length,
757
832
  className: "carousel",
758
- label: copy.title,
833
+ label: copy.rentalsFound,
759
834
  children: props.items.map((item) => /* @__PURE__ */ jsx(Card, {
760
835
  rentalId: item.rentalId,
761
836
  stayId: item.stayId
@@ -763,21 +838,17 @@ const { registry } = defineRegistry(chatCatalog, { components: {
763
838
  });
764
839
  },
765
840
  StayQuote: ({ props }) => {
766
- const { copy, locale } = useChat();
767
841
  const stay = useStay(props.stayId);
768
842
  const rental = useRental(stay?.rentalId ?? "");
769
843
  if (!stay) return null;
770
844
  return /* @__PURE__ */ jsxs("article", {
771
845
  className: "card stay-card",
772
- children: [/* @__PURE__ */ jsxs("div", {
846
+ children: [/* @__PURE__ */ jsx("div", {
773
847
  className: "card-body",
774
- children: [rental && /* @__PURE__ */ jsx("h3", {
848
+ children: rental && /* @__PURE__ */ jsx("h3", {
775
849
  className: "card-title",
776
850
  children: rental.name
777
- }), /* @__PURE__ */ jsx("p", {
778
- className: "specs",
779
- children: partyText(stay, copy, locale)
780
- })]
851
+ })
781
852
  }), /* @__PURE__ */ jsx(CardFoot, {
782
853
  rentalId: stay.rentalId,
783
854
  stay
@@ -785,18 +856,28 @@ const { registry } = defineRegistry(chatCatalog, { components: {
785
856
  });
786
857
  },
787
858
  PriceBreakdown: ({ props }) => {
788
- const { copy } = useChat();
859
+ const { copy, locale } = useChat();
789
860
  const stay = useStay(props.stayId);
861
+ const rental = useRental(stay?.rentalId ?? "");
790
862
  if (!stay?.bookable || !stay.total) return null;
863
+ const label = rental ? `${copy.priceBreakdown} · ${rental.name}` : copy.priceBreakdown;
791
864
  return /* @__PURE__ */ jsx("section", {
792
865
  className: "card breakdown",
793
- "aria-label": copy.priceBreakdown,
866
+ "aria-label": label,
794
867
  children: /* @__PURE__ */ jsxs("div", {
795
868
  className: "card-body",
796
869
  children: [
797
870
  /* @__PURE__ */ jsx("span", {
798
871
  className: "muted",
799
- 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
+ ]
800
881
  }),
801
882
  stay.lines.map((line) => /* @__PURE__ */ jsxs("div", {
802
883
  className: "line",
@@ -812,14 +893,26 @@ const { registry } = defineRegistry(chatCatalog, { components: {
812
893
  },
813
894
  AlternativeStays: ({ props }) => {
814
895
  const { copy } = useChat();
815
- 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", {
816
905
  className: "alternatives",
817
906
  role: "group",
818
- "aria-label": copy.otherDates,
819
- children: props.stayIds.map((stayId) => /* @__PURE__ */ jsx(AlternativeChip, {
820
- rentalId: props.rentalId,
821
- stayId
822
- }, 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
+ })]
823
916
  });
824
917
  },
825
918
  AvailabilityCalendar: ({ props }) => /* @__PURE__ */ jsx(Calendar, {
@@ -860,12 +953,12 @@ const { registry } = defineRegistry(chatCatalog, { components: {
860
953
  if (!send) return null;
861
954
  return /* @__PURE__ */ jsx("div", {
862
955
  className: "quick-replies",
863
- children: props.options.map((option) => /* @__PURE__ */ jsxs("button", {
956
+ children: props.options.map((option) => /* @__PURE__ */ jsx("button", {
864
957
  type: "button",
865
958
  className: "chip",
866
959
  disabled: busy,
867
960
  onClick: () => send(option),
868
- children: [/* @__PURE__ */ jsx(TopicIcon, { text: option }), option]
961
+ children: option
869
962
  }, option))
870
963
  });
871
964
  }
@@ -951,19 +1044,45 @@ const Consent = ({ copy, pending, onAccept, buttonRef }) => /* @__PURE__ */ jsxs
951
1044
  })
952
1045
  ]
953
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. */
954
1050
  const Thinking = ({ label }) => /* @__PURE__ */ jsxs("div", {
955
1051
  className: "thinking",
956
- role: "status",
1052
+ "aria-hidden": "true",
957
1053
  children: [
958
1054
  /* @__PURE__ */ jsx("i", {}),
959
1055
  /* @__PURE__ */ jsx("i", {}),
960
1056
  /* @__PURE__ */ jsx("i", {}),
961
1057
  /* @__PURE__ */ jsx("span", {
962
- className: "sr-only",
1058
+ className: "thinking-label",
963
1059
  children: label
964
1060
  })
965
1061
  ]
966
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"]);
967
1086
  function Panel(props) {
968
1087
  const { copy, endpoint, open, pending, onSent, onStarted, onClose } = props;
969
1088
  const stored = useMemo(() => loadConversation(endpoint), [endpoint]);
@@ -974,19 +1093,39 @@ function Panel(props) {
974
1093
  const [unavailable, setUnavailable] = useState(null);
975
1094
  const session = useRef(stored?.sessionId ?? null);
976
1095
  const abort = useRef(null);
977
- const end = useRef(null);
1096
+ const list = useRef(null);
1097
+ const following = useRef(true);
1098
+ const lastRole = useRef(stored?.messages.at(-1)?.role);
978
1099
  const start = useRef(null);
979
1100
  const composer = useRef(null);
980
1101
  const panel = useRef(null);
981
1102
  const lastActivity = useRef(stored?.savedAt ?? Date.now());
982
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"));
983
1106
  const expired = () => Date.now() - lastActivity.current >= SESSION_TTL_S * 1e3;
984
1107
  useEffect(() => () => abort.current?.abort(), []);
985
1108
  useEffect(() => {
986
- if (open) end.current?.scrollIntoView({ block: "end" });
987
- }, [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
+ ]);
988
1124
  useEffect(() => {
989
- 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();
990
1129
  }, [accepted, open]);
991
1130
  useEffect(() => {
992
1131
  onStarted(hasMessages);
@@ -1012,16 +1151,21 @@ function Panel(props) {
1012
1151
  clearConversation(endpoint);
1013
1152
  }
1014
1153
  setDraft("");
1154
+ const restoreDraft = () => setDraft((current) => current || message);
1015
1155
  setUnavailable(null);
1016
1156
  setStreaming(true);
1017
- setMessages((current) => [...fresh ? [] : current, {
1018
- role: "visitor",
1019
- parts: [{
1020
- kind: "text",
1021
- text: message
1022
- }],
1023
- unconfirmed: true
1024
- }]);
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
+ });
1025
1169
  const sentAt = Date.now();
1026
1170
  const controller = new AbortController();
1027
1171
  abort.current = controller;
@@ -1042,14 +1186,19 @@ function Panel(props) {
1042
1186
  kind: "ui",
1043
1187
  spec: chatEvent.spec
1044
1188
  }));
1045
- else if (chatEvent.type === "unavailable") setUnavailable(chatEvent.reason);
1046
- 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") {
1047
1193
  lastActivity.current = sentAt;
1048
1194
  setMessages(confirmed);
1049
1195
  }
1050
1196
  }, controller.signal);
1051
1197
  } catch {
1052
- if (!controller.signal.aborted) setUnavailable("error");
1198
+ if (!controller.signal.aborted) {
1199
+ setUnavailable("error");
1200
+ restoreDraft();
1201
+ }
1053
1202
  } finally {
1054
1203
  if (abort.current === controller) setStreaming(false);
1055
1204
  }
@@ -1094,7 +1243,8 @@ function Panel(props) {
1094
1243
  const focusable = [...element.querySelectorAll("a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled])")];
1095
1244
  const active = element.getRootNode().activeElement;
1096
1245
  const edge = event.shiftKey ? focusable[0] : focusable.at(-1);
1097
- 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) {
1098
1248
  event.preventDefault();
1099
1249
  (event.shiftKey ? focusable.at(-1) : focusable[0])?.focus();
1100
1250
  }
@@ -1103,7 +1253,6 @@ function Panel(props) {
1103
1253
  event.preventDefault();
1104
1254
  submit(draft);
1105
1255
  };
1106
- const waiting = streaming && messages.at(-1)?.role === "visitor";
1107
1256
  return /* @__PURE__ */ jsxs(ChatContext.Provider, {
1108
1257
  value: {
1109
1258
  ...props,
@@ -1121,6 +1270,7 @@ function Panel(props) {
1121
1270
  "aria-modal": "true",
1122
1271
  "aria-label": copy.title,
1123
1272
  ref: panel,
1273
+ tabIndex: -1,
1124
1274
  hidden: !open,
1125
1275
  inert: !open,
1126
1276
  onKeyDown: (event) => {
@@ -1156,50 +1306,50 @@ function Panel(props) {
1156
1306
  className: "notice",
1157
1307
  children: copy.notice
1158
1308
  }),
1159
- accepted ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
1160
- className: "messages",
1161
- "aria-live": "polite",
1162
- children: [
1163
- /* @__PURE__ */ jsx("div", {
1164
- className: "message",
1165
- "data-role": "assistant",
1166
- children: copy.greeting
1167
- }),
1168
- messages.map((message, index) => /* @__PURE__ */ jsx("div", {
1169
- className: message.role === "assistant" ? "ui" : "turn",
1170
- 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", {
1171
1321
  className: "message",
1172
- "data-role": message.role,
1173
- children: message.role === "assistant" ? /* @__PURE__ */ jsx(RichText, { text: part.text }) : part.text
1174
- }, partIndex) : /* @__PURE__ */ jsx(SpecView, { spec: part.spec }, partIndex))
1175
- }, index)),
1176
- waiting && /* @__PURE__ */ jsx(Thinking, { label: copy.thinking }),
1177
- /* @__PURE__ */ jsx("div", { ref: end })
1178
- ]
1179
- }), /* @__PURE__ */ jsxs("div", {
1180
- className: "dock",
1181
- children: [
1182
- unavailable && /* @__PURE__ */ jsxs("div", {
1183
- className: "fallback",
1184
- role: "status",
1185
- children: [
1186
- /* @__PURE__ */ jsx("p", {
1187
- className: "muted",
1188
- children: unavailable === "busy" ? copy.busy : copy.unavailable
1189
- }),
1190
- /* @__PURE__ */ jsx("a", {
1191
- className: "link",
1192
- href: props.searchPath,
1193
- children: copy.searchLink
1194
- }),
1195
- /* @__PURE__ */ jsx("a", {
1196
- className: "link",
1197
- href: props.contactPath,
1198
- children: copy.contactLink
1199
- })
1200
- ]
1201
- }),
1202
- !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", {
1203
1353
  className: "suggestions",
1204
1354
  children: copy.suggestions.map((suggestion) => /* @__PURE__ */ jsxs("button", {
1205
1355
  type: "button",
@@ -1207,8 +1357,7 @@ function Panel(props) {
1207
1357
  onClick: () => void submit(suggestion),
1208
1358
  children: [/* @__PURE__ */ jsx(TopicIcon, { text: suggestion }), suggestion]
1209
1359
  }, suggestion))
1210
- }),
1211
- /* @__PURE__ */ jsxs("form", {
1360
+ }), /* @__PURE__ */ jsxs("form", {
1212
1361
  className: "composer",
1213
1362
  onSubmit: send,
1214
1363
  children: [/* @__PURE__ */ jsx("textarea", {
@@ -1231,9 +1380,9 @@ function Panel(props) {
1231
1380
  disabled: streaming || !draft.trim(),
1232
1381
  children: /* @__PURE__ */ jsx(Arrow, {})
1233
1382
  })]
1234
- })
1235
- ]
1236
- })] }) : /* @__PURE__ */ jsx(Consent, {
1383
+ })]
1384
+ })
1385
+ ] }) : /* @__PURE__ */ jsx(Consent, {
1237
1386
  copy,
1238
1387
  pending,
1239
1388
  onAccept: () => setAccepted(true),