@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.
- package/README.md +1 -0
- package/dist/{catalog-ClcCUjf7.js → catalog-Buxzskkg.js} +10 -4
- package/dist/catalog.d.ts +82 -2
- package/dist/catalog.js +2 -2
- package/dist/{icons-BK5Lk0cP.js → icons-B0_KM_uQ.js} +17 -1
- package/dist/index.d.ts +88 -3
- package/dist/index.js +484 -54
- package/dist/panel-B_wg4Wl0.js +2594 -0
- package/dist/{protocol-Bmb_uxVQ.d.ts → protocol-Dg1HZNWG.d.ts} +12 -1
- package/instructions/CHANGELOG.md +1 -0
- package/instructions/MIGRATION.md +1 -0
- package/instructions/versions/1.3.0/CHANGELOG.md +82 -0
- package/instructions/versions/1.3.0/MIGRATION.md +22 -0
- package/package.json +1 -1
- package/dist/panel-CfS3SuRX.js +0 -1396
|
@@ -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 };
|