@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
package/dist/panel-CfS3SuRX.js
DELETED
|
@@ -1,1396 +0,0 @@
|
|
|
1
|
-
import { i as SESSION_TTL_S, r as MAX_MESSAGE_CHARS } from "./protocol-BuNqZAMt.js";
|
|
2
|
-
import { a as House, c as TopicIcon, d as saveConversation, i as Cross, l as clearConversation, n as Check, o as Restart, p as sendMessage, r as Chevron, t as Arrow, u as loadConversation } from "./icons-BK5Lk0cP.js";
|
|
3
|
-
import { r as chatCatalog, u as statePath } from "./catalog-ClcCUjf7.js";
|
|
4
|
-
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
-
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
-
import { JSONUIProvider, Renderer, defineRegistry, useStateValue } from "@json-render/react";
|
|
7
|
-
import { DEFAULT_MAP_STYLE_URL, partyPairs } from "@v-office/website-headless";
|
|
8
|
-
//#region src/links.ts
|
|
9
|
-
const withQuery = (path, pairs) => `${path}${path.includes("?") ? "&" : "?"}${new URLSearchParams(pairs)}`;
|
|
10
|
-
/** The stay's dates, party and chosen rate, as the rental page and the checkout both read them. */
|
|
11
|
-
const stayPairs = (stay) => [
|
|
12
|
-
["start", stay.start],
|
|
13
|
-
["end", stay.end],
|
|
14
|
-
...partyPairs(stay.party, "pets").map(([key, value]) => [key, value]),
|
|
15
|
-
...stay.policyId ? [["policy", stay.policyId]] : []
|
|
16
|
-
];
|
|
17
|
-
/** See {@link StayHref}. */
|
|
18
|
-
const stayHref = (page, stay, searchPath) => {
|
|
19
|
-
const path = page ?? searchPath;
|
|
20
|
-
return stay ? withQuery(path, stayPairs(stay)) : path;
|
|
21
|
-
};
|
|
22
|
-
/** See {@link BookingHref}. */
|
|
23
|
-
const bookingHref = (bookingPage, stay) => withQuery(bookingPage, [...stayPairs(stay), ...(stay.services ?? []).filter((service) => service.count > 0).map((service) => ["service", `${service.id}:${service.count}`])]);
|
|
24
|
-
//#endregion
|
|
25
|
-
//#region src/map.tsx
|
|
26
|
-
/** MapLibre and its worker, loaded on the first tap. MapLibre finds its worker beside its own module
|
|
27
|
-
* by default; bundled, the module is a hashed chunk with nothing beside it, so `?worker&url` has the
|
|
28
|
-
* site's bundler emit the worker as one self-contained file (MapLibre's own Vite setup), and
|
|
29
|
-
* `setWorkerUrl` points MapLibre at it before the first map reads it. */
|
|
30
|
-
const loadMapLibre = () => Promise.all([import("maplibre-gl"), import("maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url")]).then(([library, worker]) => {
|
|
31
|
-
library.setWorkerUrl(worker.default);
|
|
32
|
-
return library;
|
|
33
|
-
});
|
|
34
|
-
/** What the style's sources ask to be credited, with their links, as MapLibre's attribution control
|
|
35
|
-
* would show it (OpenStreetMap's to its copyright page). */
|
|
36
|
-
const Attribution = () => /* @__PURE__ */ jsxs("p", {
|
|
37
|
-
className: "map-attribution",
|
|
38
|
-
children: [
|
|
39
|
-
/* @__PURE__ */ jsx("a", {
|
|
40
|
-
href: "https://openfreemap.org",
|
|
41
|
-
target: "_blank",
|
|
42
|
-
rel: "noopener noreferrer",
|
|
43
|
-
children: "OpenFreeMap"
|
|
44
|
-
}),
|
|
45
|
-
" ",
|
|
46
|
-
/* @__PURE__ */ jsx("a", {
|
|
47
|
-
href: "https://www.openmaptiles.org/",
|
|
48
|
-
target: "_blank",
|
|
49
|
-
rel: "noopener noreferrer",
|
|
50
|
-
children: "© OpenMapTiles"
|
|
51
|
-
}),
|
|
52
|
-
" ",
|
|
53
|
-
"Data from",
|
|
54
|
-
" ",
|
|
55
|
-
/* @__PURE__ */ jsx("a", {
|
|
56
|
-
href: "https://www.openstreetmap.org/copyright",
|
|
57
|
-
target: "_blank",
|
|
58
|
-
rel: "noopener noreferrer",
|
|
59
|
-
children: "OpenStreetMap"
|
|
60
|
-
})
|
|
61
|
-
]
|
|
62
|
-
});
|
|
63
|
-
const MapCard = ({ rental, label, notice, load, children }) => {
|
|
64
|
-
const [shown, setShown] = useState(false);
|
|
65
|
-
const box = useRef(null);
|
|
66
|
-
const location = rental.location;
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
const element = box.current;
|
|
69
|
-
if (!shown || !element || !location) return;
|
|
70
|
-
let removed = false;
|
|
71
|
-
let map;
|
|
72
|
-
loadMapLibre().then(({ Map, Marker }) => {
|
|
73
|
-
if (removed) return;
|
|
74
|
-
const center = [location.lng, location.lat];
|
|
75
|
-
const created = new Map({
|
|
76
|
-
container: element,
|
|
77
|
-
style: DEFAULT_MAP_STYLE_URL,
|
|
78
|
-
center,
|
|
79
|
-
zoom: 13,
|
|
80
|
-
attributionControl: false,
|
|
81
|
-
scrollZoom: false
|
|
82
|
-
});
|
|
83
|
-
const pin = document.createElement("span");
|
|
84
|
-
pin.className = "map-pin";
|
|
85
|
-
new Marker({ element: pin }).setLngLat(center).addTo(created);
|
|
86
|
-
map = created;
|
|
87
|
-
}).catch(() => {
|
|
88
|
-
if (!removed) setShown(false);
|
|
89
|
-
});
|
|
90
|
-
return () => {
|
|
91
|
-
removed = true;
|
|
92
|
-
map?.remove();
|
|
93
|
-
};
|
|
94
|
-
}, [shown, location]);
|
|
95
|
-
if (!location) return null;
|
|
96
|
-
return /* @__PURE__ */ jsxs("article", {
|
|
97
|
-
className: "card map-card",
|
|
98
|
-
children: [shown ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
|
|
99
|
-
ref: box,
|
|
100
|
-
className: "map",
|
|
101
|
-
role: "group",
|
|
102
|
-
"aria-label": label
|
|
103
|
-
}), /* @__PURE__ */ jsx(Attribution, {})] }) : /* @__PURE__ */ jsxs("div", {
|
|
104
|
-
className: "map-gate",
|
|
105
|
-
children: [/* @__PURE__ */ jsx("p", {
|
|
106
|
-
className: "muted",
|
|
107
|
-
children: notice
|
|
108
|
-
}), /* @__PURE__ */ jsx("button", {
|
|
109
|
-
type: "button",
|
|
110
|
-
className: "button",
|
|
111
|
-
onClick: () => setShown(true),
|
|
112
|
-
children: load
|
|
113
|
-
})]
|
|
114
|
-
}), children]
|
|
115
|
-
});
|
|
116
|
-
};
|
|
117
|
-
//#endregion
|
|
118
|
-
//#region src/registry.tsx
|
|
119
|
-
const ChatContext = createContext(null);
|
|
120
|
-
const useChat = () => {
|
|
121
|
-
const value = useContext(ChatContext);
|
|
122
|
-
if (!value) throw new Error("A chat component rendered outside the chat panel.");
|
|
123
|
-
return value;
|
|
124
|
-
};
|
|
125
|
-
/** A stay from the state; with a `rentalId`, only a stay of that rental, so a price never shows
|
|
126
|
-
* under another rental's name, whatever a spec pairs. */
|
|
127
|
-
const useStay = (stayId, rentalId) => {
|
|
128
|
-
const stay = useStateValue(statePath("stays", stayId ?? "")) ?? null;
|
|
129
|
-
return stay && (rentalId === void 0 || stay.rentalId === rentalId) ? stay : null;
|
|
130
|
-
};
|
|
131
|
-
const useRental = (rentalId) => useStateValue(statePath("rentals", rentalId)) ?? null;
|
|
132
|
-
const day = (value) => /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
|
|
133
|
-
/** The locale dates are formatted in: British English ("13 May"), as the replies write dates and as
|
|
134
|
-
* European visitors read them, never the US order "5/13" that a bare "en" gives. */
|
|
135
|
-
const dateLocale = (locale) => locale === "en" ? "en-GB" : "de-DE";
|
|
136
|
-
/** "12.–19. Juli 2027" from two `YYYY-MM-DD` days. */
|
|
137
|
-
const period = (stay, locale) => new Intl.DateTimeFormat(dateLocale(locale), {
|
|
138
|
-
day: "numeric",
|
|
139
|
-
month: "short",
|
|
140
|
-
year: "numeric",
|
|
141
|
-
timeZone: "UTC"
|
|
142
|
-
}).formatRange(day(stay.start), day(stay.end));
|
|
143
|
-
/** "14.–21. Juli", "26. Dez.–2. Jan.": the short form, without years even over New Year. */
|
|
144
|
-
const shortPeriod = (stay, locale) => {
|
|
145
|
-
const short = new Intl.DateTimeFormat(dateLocale(locale), {
|
|
146
|
-
day: "numeric",
|
|
147
|
-
month: "short",
|
|
148
|
-
timeZone: "UTC"
|
|
149
|
-
});
|
|
150
|
-
return stay.start.slice(0, 4) === stay.end.slice(0, 4) ? short.formatRange(day(stay.start), day(stay.end)) : `${short.format(day(stay.start))}–${short.format(day(stay.end))}`;
|
|
151
|
-
};
|
|
152
|
-
/** "1 Kind", "2 Kinder": a count in its plural form, its number and noun joined by a no-break space,
|
|
153
|
-
* so a narrow card never ends a line on the "2". */
|
|
154
|
-
const counted = (forms, n, locale) => (forms[new Intl.PluralRules(locale).select(n)] ?? forms.other).replace("{n} ", `${n}\u00a0`).replace("{n}", String(n));
|
|
155
|
-
/** "2 Schlafzimmer" from the PMS, its number and noun joined by a no-break space as in `counted`. */
|
|
156
|
-
const keepCount = (fact) => fact.replace(/^(\d+) /, "$1\xA0");
|
|
157
|
-
/** A review count as the PMS formats it ("36", or "1 Bewertungen" on v10), as the number it states. */
|
|
158
|
-
const reviewCount = (count) => Number(count.replace(/\D/g, ""));
|
|
159
|
-
const partyText = (stay, copy, locale) => {
|
|
160
|
-
const children = stay.party.children + stay.party.babies;
|
|
161
|
-
return [
|
|
162
|
-
counted(copy.adults, stay.party.adults, locale),
|
|
163
|
-
...children ? [counted(copy.children, children, locale)] : [],
|
|
164
|
-
...stay.party.pets ? [counted(copy.pets, stay.party.pets, locale)] : []
|
|
165
|
-
].join(", ");
|
|
166
|
-
};
|
|
167
|
-
/** A bookable stay goes to the rental's booking page with everything chosen, where the site has one;
|
|
168
|
-
* otherwise to the rental page (or the search) with the stay filled in. */
|
|
169
|
-
const useBookHref = (rentalId, stay) => {
|
|
170
|
-
const { searchPath } = useChat();
|
|
171
|
-
const page = useStateValue(statePath("pages", rentalId)) ?? null;
|
|
172
|
-
const booking = useStateValue(statePath("bookingPages", rentalId)) ?? null;
|
|
173
|
-
if (stay?.bookable && booking) return bookingHref(booking, stay);
|
|
174
|
-
return stayHref(page, stay?.bookable ? stay : null, searchPath);
|
|
175
|
-
};
|
|
176
|
-
/** A link out of the chat. It waits while a reply streams: leaving the page then aborts the turn,
|
|
177
|
-
* which neither the service nor the widget keeps, and a first conversation would be lost. */
|
|
178
|
-
const ReplyLink = ({ href, className, children }) => {
|
|
179
|
-
const { busy } = useChat();
|
|
180
|
-
return busy ? /* @__PURE__ */ jsx("span", {
|
|
181
|
-
className,
|
|
182
|
-
"aria-disabled": "true",
|
|
183
|
-
children
|
|
184
|
-
}) : /* @__PURE__ */ jsx("a", {
|
|
185
|
-
className,
|
|
186
|
-
href,
|
|
187
|
-
children
|
|
188
|
-
});
|
|
189
|
-
};
|
|
190
|
-
/** What a booking link is for, for a screen reader: in a links list, eight "Buchen" say nothing. */
|
|
191
|
-
const LinkTarget = ({ rentalId, stay }) => {
|
|
192
|
-
const { locale } = useChat();
|
|
193
|
-
const rental = useRental(rentalId);
|
|
194
|
-
if (!rental) return null;
|
|
195
|
-
return /* @__PURE__ */ jsx("span", {
|
|
196
|
-
className: "sr-only",
|
|
197
|
-
children: `, ${rental.name}${stay?.bookable ? `, ${shortPeriod(stay, locale)}` : ""}`
|
|
198
|
-
});
|
|
199
|
-
};
|
|
200
|
-
const BookLink = ({ rentalId, stay }) => {
|
|
201
|
-
const { copy } = useChat();
|
|
202
|
-
return /* @__PURE__ */ jsxs(ReplyLink, {
|
|
203
|
-
className: "button",
|
|
204
|
-
href: useBookHref(rentalId, stay),
|
|
205
|
-
children: [stay?.bookable ? copy.bookStay : copy.viewRental, /* @__PURE__ */ jsx(LinkTarget, {
|
|
206
|
-
rentalId,
|
|
207
|
-
stay
|
|
208
|
-
})]
|
|
209
|
-
});
|
|
210
|
-
};
|
|
211
|
-
/** A row that snaps item by item, with arrow buttons and "n / total"; swipe, scroll or keys move it. */
|
|
212
|
-
const Snapper = ({ count, className, label, children }) => {
|
|
213
|
-
const { copy } = useChat();
|
|
214
|
-
const row = useRef(null);
|
|
215
|
-
const [index, setIndex] = useState(0);
|
|
216
|
-
const range = (element) => Math.max(1, element.scrollWidth - element.clientWidth);
|
|
217
|
-
const go = (step) => {
|
|
218
|
-
const element = row.current;
|
|
219
|
-
if (!element) return;
|
|
220
|
-
const still = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
221
|
-
const [first, second] = element.children;
|
|
222
|
-
const card = first && second ? second.offsetLeft - first.offsetLeft : element.clientWidth;
|
|
223
|
-
element.scrollBy({
|
|
224
|
-
left: step * card,
|
|
225
|
-
behavior: still ? "auto" : "smooth"
|
|
226
|
-
});
|
|
227
|
-
};
|
|
228
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
229
|
-
className: `${className}-wrap`,
|
|
230
|
-
role: "group",
|
|
231
|
-
"aria-roledescription": "carousel",
|
|
232
|
-
"aria-label": label,
|
|
233
|
-
children: [/* @__PURE__ */ jsx("div", {
|
|
234
|
-
ref: row,
|
|
235
|
-
className,
|
|
236
|
-
onScroll: (event) => {
|
|
237
|
-
const element = event.currentTarget;
|
|
238
|
-
setIndex(Math.round(element.scrollLeft / range(element) * (count - 1)));
|
|
239
|
-
},
|
|
240
|
-
children
|
|
241
|
-
}), count > 1 && /* @__PURE__ */ jsxs("div", {
|
|
242
|
-
className: "snap-nav",
|
|
243
|
-
children: [
|
|
244
|
-
/* @__PURE__ */ jsx("button", {
|
|
245
|
-
type: "button",
|
|
246
|
-
"aria-label": copy.previous,
|
|
247
|
-
disabled: index === 0,
|
|
248
|
-
onClick: () => go(-1),
|
|
249
|
-
children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
|
|
250
|
-
}),
|
|
251
|
-
/* @__PURE__ */ jsxs("span", {
|
|
252
|
-
className: "muted",
|
|
253
|
-
children: [
|
|
254
|
-
Math.min(index + 1, count),
|
|
255
|
-
" / ",
|
|
256
|
-
count
|
|
257
|
-
]
|
|
258
|
-
}),
|
|
259
|
-
/* @__PURE__ */ jsx("button", {
|
|
260
|
-
type: "button",
|
|
261
|
-
"aria-label": copy.next,
|
|
262
|
-
disabled: index >= count - 1,
|
|
263
|
-
onClick: () => go(1),
|
|
264
|
-
children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
|
|
265
|
-
})
|
|
266
|
-
]
|
|
267
|
-
})]
|
|
268
|
-
});
|
|
269
|
-
};
|
|
270
|
-
/** A rental's photos, swiped or stepped through with the arrows a pointer brings up; the dots say
|
|
271
|
-
* which one shows. */
|
|
272
|
-
const Gallery = ({ images, name }) => {
|
|
273
|
-
const { copy } = useChat();
|
|
274
|
-
const row = useRef(null);
|
|
275
|
-
const [index, setIndex] = useState(0);
|
|
276
|
-
if (images.length === 0) return null;
|
|
277
|
-
const go = (to) => {
|
|
278
|
-
const element = row.current;
|
|
279
|
-
if (!element) return;
|
|
280
|
-
const still = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
281
|
-
element.scrollTo({
|
|
282
|
-
left: to * element.clientWidth,
|
|
283
|
-
behavior: still ? "auto" : "smooth"
|
|
284
|
-
});
|
|
285
|
-
};
|
|
286
|
-
const many = images.length > 1;
|
|
287
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
288
|
-
className: "gallery-wrap",
|
|
289
|
-
role: "group",
|
|
290
|
-
"aria-roledescription": "carousel",
|
|
291
|
-
"aria-label": name,
|
|
292
|
-
children: [/* @__PURE__ */ jsx("div", {
|
|
293
|
-
ref: row,
|
|
294
|
-
className: "gallery",
|
|
295
|
-
onScroll: (event) => {
|
|
296
|
-
const element = event.currentTarget;
|
|
297
|
-
setIndex(Math.round(element.scrollLeft / Math.max(1, element.clientWidth)));
|
|
298
|
-
},
|
|
299
|
-
children: images.map((image, imageIndex) => /* @__PURE__ */ jsx("img", {
|
|
300
|
-
src: image.src,
|
|
301
|
-
alt: image.alt || `${name}, ${copy.photo} ${imageIndex + 1}`,
|
|
302
|
-
loading: "lazy"
|
|
303
|
-
}, image.src))
|
|
304
|
-
}), many && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
305
|
-
/* @__PURE__ */ jsx("button", {
|
|
306
|
-
type: "button",
|
|
307
|
-
className: "gallery-arrow",
|
|
308
|
-
"data-side": "prev",
|
|
309
|
-
"aria-label": copy.previous,
|
|
310
|
-
disabled: index === 0,
|
|
311
|
-
onClick: () => go(index - 1),
|
|
312
|
-
children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
|
|
313
|
-
}),
|
|
314
|
-
/* @__PURE__ */ jsx("button", {
|
|
315
|
-
type: "button",
|
|
316
|
-
className: "gallery-arrow",
|
|
317
|
-
"data-side": "next",
|
|
318
|
-
"aria-label": copy.next,
|
|
319
|
-
disabled: index >= images.length - 1,
|
|
320
|
-
onClick: () => go(index + 1),
|
|
321
|
-
children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
|
|
322
|
-
}),
|
|
323
|
-
/* @__PURE__ */ jsx("span", {
|
|
324
|
-
className: "dots",
|
|
325
|
-
"aria-hidden": "true",
|
|
326
|
-
children: images.map((image, imageIndex) => /* @__PURE__ */ jsx("i", { "data-current": imageIndex === index || void 0 }, image.src))
|
|
327
|
-
}),
|
|
328
|
-
/* @__PURE__ */ jsx("span", {
|
|
329
|
-
className: "sr-only",
|
|
330
|
-
children: `${copy.photo} ${Math.min(index + 1, images.length)} / ${images.length}`
|
|
331
|
-
})
|
|
332
|
-
] })]
|
|
333
|
-
});
|
|
334
|
-
};
|
|
335
|
-
/** The PMS's score as a chip; its number of reviews is read out, not shown. */
|
|
336
|
-
const Rating = ({ rental }) => {
|
|
337
|
-
const { copy, locale } = useChat();
|
|
338
|
-
if (!rental.rating) return null;
|
|
339
|
-
const { value, count } = rental.rating;
|
|
340
|
-
return /* @__PURE__ */ jsx("span", {
|
|
341
|
-
className: "score-chip",
|
|
342
|
-
role: "img",
|
|
343
|
-
title: counted(copy.reviews, reviewCount(count), locale),
|
|
344
|
-
"aria-label": `${value}, ${counted(copy.reviews, reviewCount(count), locale)}`,
|
|
345
|
-
children: value
|
|
346
|
-
});
|
|
347
|
-
};
|
|
348
|
-
/** The visitor's wishes the latest search that found the rental applied, which it therefore has. */
|
|
349
|
-
const useWishes = (rentalId) => useStateValue(statePath("wishes", rentalId)) ?? [];
|
|
350
|
-
const Wishes = ({ wishes }) => {
|
|
351
|
-
const { copy } = useChat();
|
|
352
|
-
if (wishes.length === 0) return null;
|
|
353
|
-
return /* @__PURE__ */ jsx("ul", {
|
|
354
|
-
className: "wishes",
|
|
355
|
-
"aria-label": copy.matchesWishes,
|
|
356
|
-
children: wishes.map((wish) => /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsx(Check, {}), wish] }, wish))
|
|
357
|
-
});
|
|
358
|
-
};
|
|
359
|
-
/** "7 Nächte · 6.–13. Mai": a stay's length and dates, short. */
|
|
360
|
-
const stayMeta = (stay, copy, locale) => {
|
|
361
|
-
const nights = Math.round((day(stay.end).getTime() - day(stay.start).getTime()) / 864e5);
|
|
362
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
363
|
-
counted(copy.nights, nights, locale),
|
|
364
|
-
" ·",
|
|
365
|
-
" ",
|
|
366
|
-
/* @__PURE__ */ jsx("span", {
|
|
367
|
-
className: "period",
|
|
368
|
-
children: shortPeriod(stay, locale)
|
|
369
|
-
})
|
|
370
|
-
] });
|
|
371
|
-
};
|
|
372
|
-
/** A card's foot: the stay's total, its length and dates and the party it is for, and the way to
|
|
373
|
-
* book or to the rental. */
|
|
374
|
-
const CardFoot = ({ rentalId, stay }) => {
|
|
375
|
-
const { copy, locale } = useChat();
|
|
376
|
-
const href = useBookHref(rentalId, stay);
|
|
377
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
378
|
-
className: "card-foot",
|
|
379
|
-
children: [stay && /* @__PURE__ */ jsx("div", {
|
|
380
|
-
className: "foot-price",
|
|
381
|
-
children: !stay.bookable ? /* @__PURE__ */ jsx("span", {
|
|
382
|
-
className: "foot-meta",
|
|
383
|
-
children: copy.notBookable
|
|
384
|
-
}) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
385
|
-
stay.total && /* @__PURE__ */ jsx("span", {
|
|
386
|
-
className: "price",
|
|
387
|
-
children: stay.total
|
|
388
|
-
}),
|
|
389
|
-
/* @__PURE__ */ jsx("span", {
|
|
390
|
-
className: "foot-meta",
|
|
391
|
-
children: stayMeta(stay, copy, locale)
|
|
392
|
-
}),
|
|
393
|
-
/* @__PURE__ */ jsx("span", {
|
|
394
|
-
className: "foot-meta",
|
|
395
|
-
children: partyText(stay, copy, locale)
|
|
396
|
-
})
|
|
397
|
-
] })
|
|
398
|
-
}), /* @__PURE__ */ jsxs(ReplyLink, {
|
|
399
|
-
className: "button compact",
|
|
400
|
-
href,
|
|
401
|
-
children: [stay?.bookable ? copy.book : copy.view, /* @__PURE__ */ jsx(LinkTarget, {
|
|
402
|
-
rentalId,
|
|
403
|
-
stay
|
|
404
|
-
})]
|
|
405
|
-
})]
|
|
406
|
-
});
|
|
407
|
-
};
|
|
408
|
-
const Card = ({ rentalId, stayId }) => {
|
|
409
|
-
const rental = useRental(rentalId);
|
|
410
|
-
const stay = useStay(stayId, rentalId);
|
|
411
|
-
const wishes = useWishes(rentalId);
|
|
412
|
-
if (!rental) return null;
|
|
413
|
-
return /* @__PURE__ */ jsxs("article", {
|
|
414
|
-
className: "card rental-card",
|
|
415
|
-
children: [
|
|
416
|
-
rental.images.length > 0 ? /* @__PURE__ */ jsx(Gallery, {
|
|
417
|
-
images: rental.images,
|
|
418
|
-
name: rental.name
|
|
419
|
-
}) : /* @__PURE__ */ jsx("div", {
|
|
420
|
-
className: "no-photo",
|
|
421
|
-
"aria-hidden": "true",
|
|
422
|
-
children: /* @__PURE__ */ jsx(House, {})
|
|
423
|
-
}),
|
|
424
|
-
/* @__PURE__ */ jsxs("div", {
|
|
425
|
-
className: "card-body",
|
|
426
|
-
children: [
|
|
427
|
-
/* @__PURE__ */ jsxs("div", {
|
|
428
|
-
className: "card-meta",
|
|
429
|
-
children: [rental.place && /* @__PURE__ */ jsx("span", {
|
|
430
|
-
className: "place",
|
|
431
|
-
children: rental.place
|
|
432
|
-
}), /* @__PURE__ */ jsx(Rating, { rental })]
|
|
433
|
-
}),
|
|
434
|
-
/* @__PURE__ */ jsx("h3", {
|
|
435
|
-
className: "card-title",
|
|
436
|
-
children: rental.name
|
|
437
|
-
}),
|
|
438
|
-
rental.highlights.length > 0 && /* @__PURE__ */ jsx("p", {
|
|
439
|
-
className: "specs",
|
|
440
|
-
children: rental.highlights.slice(0, 3).map(keepCount).join(" · ")
|
|
441
|
-
}),
|
|
442
|
-
/* @__PURE__ */ jsx(Wishes, { wishes })
|
|
443
|
-
]
|
|
444
|
-
}),
|
|
445
|
-
/* @__PURE__ */ jsx(CardFoot, {
|
|
446
|
-
rentalId: rental.id,
|
|
447
|
-
stay
|
|
448
|
-
})
|
|
449
|
-
]
|
|
450
|
-
});
|
|
451
|
-
};
|
|
452
|
-
/** Monday-first weeks of one month, as `YYYY-MM-DD` days and `null` padding. */
|
|
453
|
-
const monthGrid = (month) => {
|
|
454
|
-
const first = day(`${month}-01`);
|
|
455
|
-
const days = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
|
|
456
|
-
const lead = (first.getUTCDay() + 6) % 7;
|
|
457
|
-
return [...Array.from({ length: lead }, () => null), ...Array.from({ length: days }, (_, i) => `${month}-${String(i + 1).padStart(2, "0")}`)];
|
|
458
|
-
};
|
|
459
|
-
const monthsOf = (calendar) => {
|
|
460
|
-
const months = [];
|
|
461
|
-
for (let month = calendar.from.slice(0, 7); month <= calendar.through.slice(0, 7);) {
|
|
462
|
-
months.push(month);
|
|
463
|
-
const next = day(`${month}-01`);
|
|
464
|
-
next.setUTCMonth(next.getUTCMonth() + 1);
|
|
465
|
-
month = next.toISOString().slice(0, 7);
|
|
466
|
-
}
|
|
467
|
-
return months;
|
|
468
|
-
};
|
|
469
|
-
const Calendar = ({ rentalId, stayId }) => {
|
|
470
|
-
const { copy, locale } = useChat();
|
|
471
|
-
const rental = useRental(rentalId);
|
|
472
|
-
const stay = useStay(stayId, rentalId);
|
|
473
|
-
const calendar = useStateValue(statePath("availability", rentalId)) ?? null;
|
|
474
|
-
const months = calendar ? monthsOf(calendar) : [];
|
|
475
|
-
const [shown, setShown] = useState(() => Math.max(0, stay ? months.indexOf(stay.start.slice(0, 7)) : 0));
|
|
476
|
-
if (!calendar || months.length === 0) return null;
|
|
477
|
-
const month = months[Math.min(shown, months.length - 1)];
|
|
478
|
-
const weekdays = Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(dateLocale(locale), {
|
|
479
|
-
weekday: "short",
|
|
480
|
-
timeZone: "UTC"
|
|
481
|
-
}).format(day(`2024-01-0${i + 1}`)));
|
|
482
|
-
const title = new Intl.DateTimeFormat(dateLocale(locale), {
|
|
483
|
-
month: "long",
|
|
484
|
-
year: "numeric",
|
|
485
|
-
timeZone: "UTC"
|
|
486
|
-
}).format(day(`${month}-01`));
|
|
487
|
-
const inStay = (date) => Boolean(stay && date >= stay.start && date <= stay.end);
|
|
488
|
-
const fullDate = new Intl.DateTimeFormat(dateLocale(locale), {
|
|
489
|
-
weekday: "long",
|
|
490
|
-
day: "numeric",
|
|
491
|
-
month: "long",
|
|
492
|
-
timeZone: "UTC"
|
|
493
|
-
});
|
|
494
|
-
const statusText = {
|
|
495
|
-
free: copy.free,
|
|
496
|
-
booked: copy.booked,
|
|
497
|
-
closed: copy.closed
|
|
498
|
-
};
|
|
499
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
500
|
-
className: "calendar",
|
|
501
|
-
children: [
|
|
502
|
-
/* @__PURE__ */ jsxs("div", {
|
|
503
|
-
className: "calendar-head",
|
|
504
|
-
children: [
|
|
505
|
-
/* @__PURE__ */ jsx("button", {
|
|
506
|
-
type: "button",
|
|
507
|
-
"aria-label": copy.previous,
|
|
508
|
-
disabled: shown === 0,
|
|
509
|
-
onClick: () => setShown(shown - 1),
|
|
510
|
-
children: /* @__PURE__ */ jsx(Chevron, { side: "prev" })
|
|
511
|
-
}),
|
|
512
|
-
/* @__PURE__ */ jsxs("span", { children: [title, rental ? ` · ${rental.name}` : ""] }),
|
|
513
|
-
/* @__PURE__ */ jsx("button", {
|
|
514
|
-
type: "button",
|
|
515
|
-
"aria-label": copy.next,
|
|
516
|
-
disabled: shown >= months.length - 1,
|
|
517
|
-
onClick: () => setShown(shown + 1),
|
|
518
|
-
children: /* @__PURE__ */ jsx(Chevron, { side: "next" })
|
|
519
|
-
})
|
|
520
|
-
]
|
|
521
|
-
}),
|
|
522
|
-
/* @__PURE__ */ jsxs("div", {
|
|
523
|
-
className: "calendar-grid",
|
|
524
|
-
children: [weekdays.map((weekday) => /* @__PURE__ */ jsx("span", {
|
|
525
|
-
className: "weekday",
|
|
526
|
-
children: weekday
|
|
527
|
-
}, weekday)), monthGrid(month).map((date, index) => {
|
|
528
|
-
if (!date) return /* @__PURE__ */ jsx("span", {}, `pad-${index}`);
|
|
529
|
-
const status = calendar.days[date] ?? "closed";
|
|
530
|
-
return /* @__PURE__ */ jsxs("span", {
|
|
531
|
-
className: "day",
|
|
532
|
-
"data-status": status,
|
|
533
|
-
"data-stay": inStay(date) || void 0,
|
|
534
|
-
children: [/* @__PURE__ */ jsx("span", {
|
|
535
|
-
"aria-hidden": "true",
|
|
536
|
-
children: Number(date.slice(8))
|
|
537
|
-
}), /* @__PURE__ */ jsx("span", {
|
|
538
|
-
className: "sr-only",
|
|
539
|
-
children: [
|
|
540
|
-
fullDate.format(day(date)),
|
|
541
|
-
statusText[status],
|
|
542
|
-
...inStay(date) ? [copy.yourStay] : []
|
|
543
|
-
].join(", ")
|
|
544
|
-
})]
|
|
545
|
-
}, date);
|
|
546
|
-
})]
|
|
547
|
-
}),
|
|
548
|
-
/* @__PURE__ */ jsxs("div", {
|
|
549
|
-
className: "legend muted",
|
|
550
|
-
children: [
|
|
551
|
-
/* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-status": "free" }), copy.free] }),
|
|
552
|
-
/* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-status": "booked" }), copy.booked] }),
|
|
553
|
-
stay && /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("i", { "data-stay": "true" }), copy.yourStay] })
|
|
554
|
-
]
|
|
555
|
-
})
|
|
556
|
-
]
|
|
557
|
-
});
|
|
558
|
-
};
|
|
559
|
-
const CompareColumn = ({ rentalId, stayId }) => {
|
|
560
|
-
const { copy, locale } = useChat();
|
|
561
|
-
const rental = useRental(rentalId);
|
|
562
|
-
const stay = useStay(stayId, rentalId);
|
|
563
|
-
const href = useBookHref(rentalId, stay);
|
|
564
|
-
if (!rental) return null;
|
|
565
|
-
const rooms = rental.rooms.slice(0, 3);
|
|
566
|
-
const highlights = rental.highlights.filter((highlight) => !rooms.includes(highlight));
|
|
567
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
568
|
-
className: "compare-col",
|
|
569
|
-
children: [
|
|
570
|
-
/* @__PURE__ */ jsx("strong", { children: rental.name }),
|
|
571
|
-
/* @__PURE__ */ jsxs("dl", { children: [
|
|
572
|
-
/* @__PURE__ */ jsx("dt", { children: copy.compareRating }),
|
|
573
|
-
/* @__PURE__ */ jsx("dd", { children: rental.rating ? `★ ${rental.rating.value} (${counted(copy.reviews, reviewCount(rental.rating.count), locale)})` : "–" }),
|
|
574
|
-
/* @__PURE__ */ jsx("dt", { children: copy.compareRooms }),
|
|
575
|
-
/* @__PURE__ */ jsx("dd", { children: rooms.map(keepCount).join(", ") || "–" }),
|
|
576
|
-
/* @__PURE__ */ jsx("dt", { children: copy.compareHighlights }),
|
|
577
|
-
/* @__PURE__ */ jsx("dd", { children: highlights.slice(0, 4).map(keepCount).join(", ") || "–" }),
|
|
578
|
-
stay && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("dt", { children: copy.compareStay }), /* @__PURE__ */ jsxs("dd", { children: [
|
|
579
|
-
shortPeriod(stay, locale),
|
|
580
|
-
/* @__PURE__ */ jsx("br", {}),
|
|
581
|
-
/* @__PURE__ */ jsx("span", {
|
|
582
|
-
className: "muted",
|
|
583
|
-
children: partyText(stay, copy, locale)
|
|
584
|
-
}),
|
|
585
|
-
/* @__PURE__ */ jsx("br", {}),
|
|
586
|
-
!stay.bookable ? /* @__PURE__ */ jsx("span", {
|
|
587
|
-
className: "muted",
|
|
588
|
-
children: copy.notBookable
|
|
589
|
-
}) : stay.total && /* @__PURE__ */ jsx("span", {
|
|
590
|
-
className: "price",
|
|
591
|
-
children: stay.total
|
|
592
|
-
})
|
|
593
|
-
] })] })
|
|
594
|
-
] }),
|
|
595
|
-
/* @__PURE__ */ jsxs(ReplyLink, {
|
|
596
|
-
className: "button",
|
|
597
|
-
href,
|
|
598
|
-
children: [stay?.bookable ? copy.book : copy.view, /* @__PURE__ */ jsx(LinkTarget, {
|
|
599
|
-
rentalId,
|
|
600
|
-
stay
|
|
601
|
-
})]
|
|
602
|
-
})
|
|
603
|
-
]
|
|
604
|
-
});
|
|
605
|
-
};
|
|
606
|
-
const AlternativeChip = ({ stay }) => {
|
|
607
|
-
const { locale } = useChat();
|
|
608
|
-
const href = useBookHref(stay.rentalId, stay);
|
|
609
|
-
return /* @__PURE__ */ jsx(ReplyLink, {
|
|
610
|
-
className: "chip-link",
|
|
611
|
-
href,
|
|
612
|
-
children: /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("strong", { children: shortPeriod(stay, locale) }), stay.total ? ` · ${stay.total}` : ""] })
|
|
613
|
-
});
|
|
614
|
-
};
|
|
615
|
-
/** One arrival of a price calendar: its bar, the PMS's total, the day; tapping asks for that stay. */
|
|
616
|
-
const PriceColumn = ({ rentalId, option, asked, cheapest }) => {
|
|
617
|
-
const { copy, locale, send, busy } = useChat();
|
|
618
|
-
const rental = useRental(rentalId);
|
|
619
|
-
const stay = useStay(option.stayId, rentalId);
|
|
620
|
-
const free = Boolean(stay?.bookable && stay.total);
|
|
621
|
-
const arrival = (options) => new Intl.DateTimeFormat(dateLocale(locale), {
|
|
622
|
-
...options,
|
|
623
|
-
timeZone: "UTC"
|
|
624
|
-
}).format(day(option.start));
|
|
625
|
-
const dates = period(option, locale);
|
|
626
|
-
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
|
|
627
|
-
type: "button",
|
|
628
|
-
className: "price-column",
|
|
629
|
-
"data-asked": asked || void 0,
|
|
630
|
-
"data-cheapest": cheapest || void 0,
|
|
631
|
-
"data-free": free || void 0,
|
|
632
|
-
disabled: !free || !send || busy,
|
|
633
|
-
"aria-label": [
|
|
634
|
-
dates,
|
|
635
|
-
free ? stay.total : copy.closed,
|
|
636
|
-
...cheapest ? [copy.cheapest] : [],
|
|
637
|
-
...asked ? [copy.yourStay] : []
|
|
638
|
-
].join(", "),
|
|
639
|
-
onClick: () => send?.(rental ? `${rental.name}: ${dates}` : dates),
|
|
640
|
-
children: [
|
|
641
|
-
/* @__PURE__ */ jsx("span", {
|
|
642
|
-
className: "bar-track",
|
|
643
|
-
"aria-hidden": "true",
|
|
644
|
-
children: free && /* @__PURE__ */ jsx("span", {
|
|
645
|
-
className: "bar",
|
|
646
|
-
style: { height: `${25 + 75 * (option.level ?? 1)}%` }
|
|
647
|
-
})
|
|
648
|
-
}),
|
|
649
|
-
/* @__PURE__ */ jsx("span", {
|
|
650
|
-
className: "price-text",
|
|
651
|
-
"aria-hidden": "true",
|
|
652
|
-
children: free ? stay.total : copy.closed
|
|
653
|
-
}),
|
|
654
|
-
/* @__PURE__ */ jsxs("span", {
|
|
655
|
-
className: "muted",
|
|
656
|
-
"aria-hidden": "true",
|
|
657
|
-
children: [
|
|
658
|
-
arrival({ weekday: "short" }),
|
|
659
|
-
/* @__PURE__ */ jsx("br", {}),
|
|
660
|
-
arrival({
|
|
661
|
-
day: "numeric",
|
|
662
|
-
month: "short"
|
|
663
|
-
})
|
|
664
|
-
]
|
|
665
|
-
})
|
|
666
|
-
]
|
|
667
|
-
}) });
|
|
668
|
-
};
|
|
669
|
-
/** Which ends of a scrolled row have more beyond them, for the fade there. */
|
|
670
|
-
const hiddenEdges = (row) => {
|
|
671
|
-
const start = row.scrollLeft > 1;
|
|
672
|
-
const end = row.scrollLeft + row.clientWidth < row.scrollWidth - 1;
|
|
673
|
-
return start && end ? "both" : start ? "start" : end ? "end" : void 0;
|
|
674
|
-
};
|
|
675
|
-
const PriceCalendarView = ({ rentalId }) => {
|
|
676
|
-
const { copy, locale } = useChat();
|
|
677
|
-
const rental = useRental(rentalId);
|
|
678
|
-
const calendar = useStateValue(statePath("priceCalendars", rentalId)) ?? null;
|
|
679
|
-
const row = useRef(null);
|
|
680
|
-
const [fade, setFade] = useState();
|
|
681
|
-
useEffect(() => {
|
|
682
|
-
const element = row.current;
|
|
683
|
-
if (!element || !calendar) return;
|
|
684
|
-
const cheapest = calendar.options.findIndex((option) => option.stayId !== null && option.stayId === calendar.cheapest);
|
|
685
|
-
const asked = calendar.options.findIndex((option) => option.start === calendar.asked);
|
|
686
|
-
const column = element.children[cheapest >= 0 ? cheapest : asked];
|
|
687
|
-
if (column) {
|
|
688
|
-
const end = column.offsetLeft + column.offsetWidth;
|
|
689
|
-
if (end > element.clientWidth) element.scrollLeft = end - element.clientWidth;
|
|
690
|
-
}
|
|
691
|
-
setFade(hiddenEdges(element));
|
|
692
|
-
}, [calendar]);
|
|
693
|
-
if (!calendar || calendar.options.length === 0) return null;
|
|
694
|
-
return /* @__PURE__ */ jsx("section", {
|
|
695
|
-
className: "card price-calendar",
|
|
696
|
-
"aria-label": copy.priceCalendar,
|
|
697
|
-
children: /* @__PURE__ */ jsxs("div", {
|
|
698
|
-
className: "card-body",
|
|
699
|
-
children: [
|
|
700
|
-
/* @__PURE__ */ jsx("h3", {
|
|
701
|
-
className: "card-title",
|
|
702
|
-
children: copy.priceCalendar
|
|
703
|
-
}),
|
|
704
|
-
/* @__PURE__ */ jsxs("span", {
|
|
705
|
-
className: "muted",
|
|
706
|
-
children: [counted(copy.priceCalendarNights, calendar.nights, locale), rental ? ` · ${rental.name}` : ""]
|
|
707
|
-
}),
|
|
708
|
-
/* @__PURE__ */ jsx("ul", {
|
|
709
|
-
ref: row,
|
|
710
|
-
className: "price-columns",
|
|
711
|
-
"data-fade": fade,
|
|
712
|
-
onScroll: (event) => setFade(hiddenEdges(event.currentTarget)),
|
|
713
|
-
children: calendar.options.map((option) => /* @__PURE__ */ jsx(PriceColumn, {
|
|
714
|
-
rentalId,
|
|
715
|
-
option,
|
|
716
|
-
asked: option.start === calendar.asked,
|
|
717
|
-
cheapest: option.stayId !== null && option.stayId === calendar.cheapest
|
|
718
|
-
}, option.start))
|
|
719
|
-
}),
|
|
720
|
-
/* @__PURE__ */ jsxs("div", {
|
|
721
|
-
className: "legend muted",
|
|
722
|
-
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] })]
|
|
723
|
-
})
|
|
724
|
-
]
|
|
725
|
-
})
|
|
726
|
-
});
|
|
727
|
-
};
|
|
728
|
-
const ReviewsView = ({ rentalId }) => {
|
|
729
|
-
const { copy, locale } = useChat();
|
|
730
|
-
const rental = useRental(rentalId);
|
|
731
|
-
const reviews = useStateValue(statePath("reviews", rentalId)) ?? null;
|
|
732
|
-
if (!reviews) return null;
|
|
733
|
-
return /* @__PURE__ */ jsx("section", {
|
|
734
|
-
className: "card reviews",
|
|
735
|
-
"aria-label": copy.reviewsTitle,
|
|
736
|
-
children: /* @__PURE__ */ jsxs("div", {
|
|
737
|
-
className: "card-body",
|
|
738
|
-
children: [
|
|
739
|
-
/* @__PURE__ */ jsxs("h3", {
|
|
740
|
-
className: "card-title",
|
|
741
|
-
children: [copy.reviewsTitle, rental ? ` · ${rental.name}` : ""]
|
|
742
|
-
}),
|
|
743
|
-
/* @__PURE__ */ jsxs("div", {
|
|
744
|
-
className: "score",
|
|
745
|
-
children: [/* @__PURE__ */ jsx("span", {
|
|
746
|
-
className: "score-value",
|
|
747
|
-
children: reviews.rating
|
|
748
|
-
}), /* @__PURE__ */ jsxs("span", { children: [reviews.grade && /* @__PURE__ */ jsx("strong", { children: reviews.grade }), /* @__PURE__ */ jsxs("span", {
|
|
749
|
-
className: "muted",
|
|
750
|
-
children: [" ", counted(copy.reviews, reviewCount(reviews.count), locale)]
|
|
751
|
-
})] })]
|
|
752
|
-
}),
|
|
753
|
-
reviews.categories.length > 0 && /* @__PURE__ */ jsx("dl", {
|
|
754
|
-
className: "review-categories",
|
|
755
|
-
children: reviews.categories.map((category) => /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", { children: category.label }), /* @__PURE__ */ jsx("dd", { children: category.rating })] }, category.label))
|
|
756
|
-
}),
|
|
757
|
-
reviews.excerpts.map((excerpt) => /* @__PURE__ */ jsx("blockquote", {
|
|
758
|
-
className: "excerpt",
|
|
759
|
-
children: /* @__PURE__ */ jsx("p", { children: excerpt.text })
|
|
760
|
-
}, excerpt.text))
|
|
761
|
-
]
|
|
762
|
-
})
|
|
763
|
-
});
|
|
764
|
-
};
|
|
765
|
-
const FactsView = ({ rentalId, groups }) => {
|
|
766
|
-
const rental = useRental(rentalId);
|
|
767
|
-
const known = useStateValue(statePath("facts", rentalId)) ?? [];
|
|
768
|
-
const shown = groups.flatMap((label) => {
|
|
769
|
-
const group = known.find((candidate) => candidate.label.toLowerCase() === label.toLowerCase());
|
|
770
|
-
return group ? [group] : [];
|
|
771
|
-
});
|
|
772
|
-
if (!rental || shown.length === 0) return null;
|
|
773
|
-
return /* @__PURE__ */ jsx("section", {
|
|
774
|
-
className: "card facts",
|
|
775
|
-
"aria-label": rental.name,
|
|
776
|
-
children: /* @__PURE__ */ jsxs("div", {
|
|
777
|
-
className: "card-body",
|
|
778
|
-
children: [/* @__PURE__ */ jsx("h3", {
|
|
779
|
-
className: "card-title",
|
|
780
|
-
children: rental.name
|
|
781
|
-
}), shown.map((group) => /* @__PURE__ */ jsxs("div", {
|
|
782
|
-
className: "fact-group",
|
|
783
|
-
children: [/* @__PURE__ */ jsx("h4", { children: group.label }), /* @__PURE__ */ jsx("ul", { children: group.items.map((item) => /* @__PURE__ */ jsx("li", { children: item }, item)) })]
|
|
784
|
-
}, group.label))]
|
|
785
|
-
})
|
|
786
|
-
});
|
|
787
|
-
};
|
|
788
|
-
const RentalMapView = ({ rentalId }) => {
|
|
789
|
-
const { copy } = useChat();
|
|
790
|
-
const rental = useRental(rentalId);
|
|
791
|
-
const href = useBookHref(rentalId, null);
|
|
792
|
-
if (!rental) return null;
|
|
793
|
-
return /* @__PURE__ */ jsx(MapCard, {
|
|
794
|
-
rental,
|
|
795
|
-
label: copy.mapLabel.replace("{name}", rental.name),
|
|
796
|
-
notice: copy.mapNotice,
|
|
797
|
-
load: copy.mapLoad,
|
|
798
|
-
children: /* @__PURE__ */ jsxs("div", {
|
|
799
|
-
className: "card-body",
|
|
800
|
-
children: [
|
|
801
|
-
/* @__PURE__ */ jsx("h3", {
|
|
802
|
-
className: "card-title",
|
|
803
|
-
children: rental.name
|
|
804
|
-
}),
|
|
805
|
-
rental.place && /* @__PURE__ */ jsx("span", {
|
|
806
|
-
className: "muted",
|
|
807
|
-
children: rental.place
|
|
808
|
-
}),
|
|
809
|
-
/* @__PURE__ */ jsx(ReplyLink, {
|
|
810
|
-
className: "button",
|
|
811
|
-
href,
|
|
812
|
-
children: copy.viewRental
|
|
813
|
-
})
|
|
814
|
-
]
|
|
815
|
-
})
|
|
816
|
-
});
|
|
817
|
-
};
|
|
818
|
-
/** The registry the panel renders specs with. */
|
|
819
|
-
const { registry } = defineRegistry(chatCatalog, { components: {
|
|
820
|
-
Stack: ({ children }) => /* @__PURE__ */ jsx("div", {
|
|
821
|
-
className: "ui",
|
|
822
|
-
children
|
|
823
|
-
}),
|
|
824
|
-
RentalCard: ({ props }) => /* @__PURE__ */ jsx(Card, {
|
|
825
|
-
rentalId: props.rentalId,
|
|
826
|
-
stayId: props.stayId
|
|
827
|
-
}),
|
|
828
|
-
RentalCarousel: ({ props }) => {
|
|
829
|
-
const { copy } = useChat();
|
|
830
|
-
return /* @__PURE__ */ jsx(Snapper, {
|
|
831
|
-
count: props.items.length,
|
|
832
|
-
className: "carousel",
|
|
833
|
-
label: copy.rentalsFound,
|
|
834
|
-
children: props.items.map((item) => /* @__PURE__ */ jsx(Card, {
|
|
835
|
-
rentalId: item.rentalId,
|
|
836
|
-
stayId: item.stayId
|
|
837
|
-
}, `${item.rentalId}:${item.stayId}`))
|
|
838
|
-
});
|
|
839
|
-
},
|
|
840
|
-
StayQuote: ({ props }) => {
|
|
841
|
-
const stay = useStay(props.stayId);
|
|
842
|
-
const rental = useRental(stay?.rentalId ?? "");
|
|
843
|
-
if (!stay) return null;
|
|
844
|
-
return /* @__PURE__ */ jsxs("article", {
|
|
845
|
-
className: "card stay-card",
|
|
846
|
-
children: [/* @__PURE__ */ jsx("div", {
|
|
847
|
-
className: "card-body",
|
|
848
|
-
children: rental && /* @__PURE__ */ jsx("h3", {
|
|
849
|
-
className: "card-title",
|
|
850
|
-
children: rental.name
|
|
851
|
-
})
|
|
852
|
-
}), /* @__PURE__ */ jsx(CardFoot, {
|
|
853
|
-
rentalId: stay.rentalId,
|
|
854
|
-
stay
|
|
855
|
-
})]
|
|
856
|
-
});
|
|
857
|
-
},
|
|
858
|
-
PriceBreakdown: ({ props }) => {
|
|
859
|
-
const { copy, locale } = useChat();
|
|
860
|
-
const stay = useStay(props.stayId);
|
|
861
|
-
const rental = useRental(stay?.rentalId ?? "");
|
|
862
|
-
if (!stay?.bookable || !stay.total) return null;
|
|
863
|
-
const label = rental ? `${copy.priceBreakdown} · ${rental.name}` : copy.priceBreakdown;
|
|
864
|
-
return /* @__PURE__ */ jsx("section", {
|
|
865
|
-
className: "card breakdown",
|
|
866
|
-
"aria-label": label,
|
|
867
|
-
children: /* @__PURE__ */ jsxs("div", {
|
|
868
|
-
className: "card-body",
|
|
869
|
-
children: [
|
|
870
|
-
/* @__PURE__ */ jsx("span", {
|
|
871
|
-
className: "muted",
|
|
872
|
-
children: label
|
|
873
|
-
}),
|
|
874
|
-
/* @__PURE__ */ jsxs("span", {
|
|
875
|
-
className: "foot-meta",
|
|
876
|
-
children: [
|
|
877
|
-
stayMeta(stay, copy, locale),
|
|
878
|
-
" · ",
|
|
879
|
-
partyText(stay, copy, locale)
|
|
880
|
-
]
|
|
881
|
-
}),
|
|
882
|
-
stay.lines.map((line) => /* @__PURE__ */ jsxs("div", {
|
|
883
|
-
className: "line",
|
|
884
|
-
children: [/* @__PURE__ */ jsx("span", { children: line.label }), /* @__PURE__ */ jsx("span", { children: line.amount })]
|
|
885
|
-
}, `${line.label}:${line.amount}`)),
|
|
886
|
-
/* @__PURE__ */ jsxs("div", {
|
|
887
|
-
className: "line total",
|
|
888
|
-
children: [/* @__PURE__ */ jsx("span", { children: copy.total }), /* @__PURE__ */ jsx("span", { children: stay.total })]
|
|
889
|
-
})
|
|
890
|
-
]
|
|
891
|
-
})
|
|
892
|
-
});
|
|
893
|
-
},
|
|
894
|
-
AlternativeStays: ({ props }) => {
|
|
895
|
-
const { copy } = useChat();
|
|
896
|
-
const rental = useRental(props.rentalId);
|
|
897
|
-
const stays = useStateValue("/stays") ?? {};
|
|
898
|
-
const bookable = props.stayIds.flatMap((stayId) => {
|
|
899
|
-
const stay = stays[stayId];
|
|
900
|
-
return stay?.rentalId === props.rentalId && stay.bookable ? [stay] : [];
|
|
901
|
-
});
|
|
902
|
-
if (bookable.length === 0) return null;
|
|
903
|
-
const label = rental ? `${copy.otherDates} · ${rental.name}` : copy.otherDates;
|
|
904
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
905
|
-
className: "alternatives",
|
|
906
|
-
role: "group",
|
|
907
|
-
"aria-label": label,
|
|
908
|
-
children: [/* @__PURE__ */ jsx("p", {
|
|
909
|
-
className: "alt-caption",
|
|
910
|
-
"aria-hidden": "true",
|
|
911
|
-
children: label
|
|
912
|
-
}), /* @__PURE__ */ jsx("div", {
|
|
913
|
-
className: "alt-chips",
|
|
914
|
-
children: bookable.map((stay) => /* @__PURE__ */ jsx(AlternativeChip, { stay }, stay.id))
|
|
915
|
-
})]
|
|
916
|
-
});
|
|
917
|
-
},
|
|
918
|
-
AvailabilityCalendar: ({ props }) => /* @__PURE__ */ jsx(Calendar, {
|
|
919
|
-
rentalId: props.rentalId,
|
|
920
|
-
stayId: props.stayId
|
|
921
|
-
}),
|
|
922
|
-
PriceCalendar: ({ props }) => /* @__PURE__ */ jsx(PriceCalendarView, { rentalId: props.rentalId }),
|
|
923
|
-
ReviewSummary: ({ props }) => /* @__PURE__ */ jsx(ReviewsView, { rentalId: props.rentalId }),
|
|
924
|
-
RentalFacts: ({ props }) => /* @__PURE__ */ jsx(FactsView, {
|
|
925
|
-
rentalId: props.rentalId,
|
|
926
|
-
groups: props.groups
|
|
927
|
-
}),
|
|
928
|
-
RentalMap: ({ props }) => /* @__PURE__ */ jsx(RentalMapView, { rentalId: props.rentalId }),
|
|
929
|
-
Compare: ({ props }) => /* @__PURE__ */ jsx("div", {
|
|
930
|
-
className: "compare",
|
|
931
|
-
children: props.items.map((item) => /* @__PURE__ */ jsx(CompareColumn, {
|
|
932
|
-
rentalId: item.rentalId,
|
|
933
|
-
stayId: item.stayId
|
|
934
|
-
}, `${item.rentalId}:${item.stayId}`))
|
|
935
|
-
}),
|
|
936
|
-
StayLink: ({ props }) => {
|
|
937
|
-
const stay = useStay(props.stayId, props.rentalId);
|
|
938
|
-
return /* @__PURE__ */ jsx(BookLink, {
|
|
939
|
-
rentalId: props.rentalId,
|
|
940
|
-
stay
|
|
941
|
-
});
|
|
942
|
-
},
|
|
943
|
-
ContactLink: () => {
|
|
944
|
-
const { copy, contactPath } = useChat();
|
|
945
|
-
return /* @__PURE__ */ jsx(ReplyLink, {
|
|
946
|
-
className: "link",
|
|
947
|
-
href: contactPath,
|
|
948
|
-
children: copy.contactLink
|
|
949
|
-
});
|
|
950
|
-
},
|
|
951
|
-
QuickReplies: ({ props }) => {
|
|
952
|
-
const { send, busy } = useChat();
|
|
953
|
-
if (!send) return null;
|
|
954
|
-
return /* @__PURE__ */ jsx("div", {
|
|
955
|
-
className: "quick-replies",
|
|
956
|
-
children: props.options.map((option) => /* @__PURE__ */ jsx("button", {
|
|
957
|
-
type: "button",
|
|
958
|
-
className: "chip",
|
|
959
|
-
disabled: busy,
|
|
960
|
-
onClick: () => send(option),
|
|
961
|
-
children: option
|
|
962
|
-
}, option))
|
|
963
|
-
});
|
|
964
|
-
}
|
|
965
|
-
} });
|
|
966
|
-
//#endregion
|
|
967
|
-
//#region src/panel.tsx
|
|
968
|
-
/** Appends one event's content to the reply being streamed (the last message). */
|
|
969
|
-
const appendToReply = (messages, part) => {
|
|
970
|
-
const last = messages.at(-1);
|
|
971
|
-
if (!last || last.role !== "assistant") return [...messages, {
|
|
972
|
-
role: "assistant",
|
|
973
|
-
parts: [part],
|
|
974
|
-
unconfirmed: true
|
|
975
|
-
}];
|
|
976
|
-
const tail = last.parts.at(-1);
|
|
977
|
-
const parts = part.kind === "text" && tail?.kind === "text" ? [...last.parts.slice(0, -1), {
|
|
978
|
-
kind: "text",
|
|
979
|
-
text: tail.text + part.text
|
|
980
|
-
}] : [...last.parts, part];
|
|
981
|
-
return [...messages.slice(0, -1), {
|
|
982
|
-
...last,
|
|
983
|
-
parts
|
|
984
|
-
}];
|
|
985
|
-
};
|
|
986
|
-
/** The service finished the turn: its messages, from its visitor message on, are the
|
|
987
|
-
* conversation's now. An earlier turn it refused stays unconfirmed, on this page only. */
|
|
988
|
-
const confirmed = (messages) => {
|
|
989
|
-
const turn = messages.findLastIndex((message) => message.role === "visitor");
|
|
990
|
-
return messages.map((message, index) => index >= turn && message.unconfirmed ? {
|
|
991
|
-
role: message.role,
|
|
992
|
-
parts: message.parts
|
|
993
|
-
} : message);
|
|
994
|
-
};
|
|
995
|
-
const SCROLL_LOCK = "data-v-office-chat-open";
|
|
996
|
-
/** Locks the page's scroll until the returned function runs. The rule lives in the page's head (a
|
|
997
|
-
* shadow root's styles cannot reach <html>) and is added once. */
|
|
998
|
-
const lockPageScroll = () => {
|
|
999
|
-
const root = document.documentElement;
|
|
1000
|
-
if (!document.getElementById(SCROLL_LOCK)) {
|
|
1001
|
-
const style = document.createElement("style");
|
|
1002
|
-
style.id = SCROLL_LOCK;
|
|
1003
|
-
style.textContent = `html[${SCROLL_LOCK}] { overflow: hidden; }`;
|
|
1004
|
-
document.head.append(style);
|
|
1005
|
-
}
|
|
1006
|
-
root.setAttribute(SCROLL_LOCK, "");
|
|
1007
|
-
return () => root.removeAttribute(SCROLL_LOCK);
|
|
1008
|
-
};
|
|
1009
|
-
/** The assistant's text, its `**bold**` (a rental's name, the dates) as strong text: nothing else
|
|
1010
|
-
* is read as markup, so the model's text never becomes HTML. */
|
|
1011
|
-
const RichText = ({ text }) => /* @__PURE__ */ jsx(Fragment, { children: text.split(/\*\*(.+?)\*\*/g).map((piece, index) => index % 2 === 1 ? /* @__PURE__ */ jsx("strong", { children: piece }, index) : piece) });
|
|
1012
|
-
const SpecView = ({ spec }) => /* @__PURE__ */ jsx(JSONUIProvider, {
|
|
1013
|
-
registry,
|
|
1014
|
-
initialState: spec.state ?? {},
|
|
1015
|
-
children: /* @__PURE__ */ jsx(Renderer, {
|
|
1016
|
-
spec,
|
|
1017
|
-
registry
|
|
1018
|
-
})
|
|
1019
|
-
});
|
|
1020
|
-
const Consent = ({ copy, pending, onAccept, buttonRef }) => /* @__PURE__ */ jsxs("div", {
|
|
1021
|
-
className: "consent",
|
|
1022
|
-
children: [
|
|
1023
|
-
/* @__PURE__ */ jsx("p", {
|
|
1024
|
-
className: "message",
|
|
1025
|
-
"data-role": "assistant",
|
|
1026
|
-
children: copy.greeting
|
|
1027
|
-
}),
|
|
1028
|
-
pending && /* @__PURE__ */ jsxs("figure", {
|
|
1029
|
-
className: "pending",
|
|
1030
|
-
children: [/* @__PURE__ */ jsx("figcaption", {
|
|
1031
|
-
className: "muted",
|
|
1032
|
-
children: copy.yourMessage
|
|
1033
|
-
}), /* @__PURE__ */ jsx("p", { children: pending })]
|
|
1034
|
-
}),
|
|
1035
|
-
/* @__PURE__ */ jsxs("div", {
|
|
1036
|
-
className: "consent-card",
|
|
1037
|
-
children: [/* @__PURE__ */ jsx("p", { children: copy.consent }), /* @__PURE__ */ jsx("button", {
|
|
1038
|
-
ref: buttonRef,
|
|
1039
|
-
type: "button",
|
|
1040
|
-
className: "button compact",
|
|
1041
|
-
onClick: onAccept,
|
|
1042
|
-
children: copy.consentAccept
|
|
1043
|
-
})]
|
|
1044
|
-
})
|
|
1045
|
-
]
|
|
1046
|
-
});
|
|
1047
|
-
/** Shown until the reply's text: a search takes seconds, and three dots alone read as nothing.
|
|
1048
|
-
* Hidden from a screen reader: inside the busy log it may go unannounced, so the panel's own status,
|
|
1049
|
-
* outside the log, says it. */
|
|
1050
|
-
const Thinking = ({ label }) => /* @__PURE__ */ jsxs("div", {
|
|
1051
|
-
className: "thinking",
|
|
1052
|
-
"aria-hidden": "true",
|
|
1053
|
-
children: [
|
|
1054
|
-
/* @__PURE__ */ jsx("i", {}),
|
|
1055
|
-
/* @__PURE__ */ jsx("i", {}),
|
|
1056
|
-
/* @__PURE__ */ jsx("i", {}),
|
|
1057
|
-
/* @__PURE__ */ jsx("span", {
|
|
1058
|
-
className: "thinking-label",
|
|
1059
|
-
children: label
|
|
1060
|
-
})
|
|
1061
|
-
]
|
|
1062
|
-
});
|
|
1063
|
-
/** Why the chat cannot answer, as its reply to the visitor's message, and where to go instead. */
|
|
1064
|
-
const Fallback = ({ reason, copy, searchPath, contactPath }) => /* @__PURE__ */ jsxs("div", {
|
|
1065
|
-
className: "fallback",
|
|
1066
|
-
role: "status",
|
|
1067
|
-
children: [/* @__PURE__ */ jsx("p", {
|
|
1068
|
-
className: "message",
|
|
1069
|
-
"data-role": "assistant",
|
|
1070
|
-
children: reason === "busy" ? copy.busy : copy.unavailable
|
|
1071
|
-
}), /* @__PURE__ */ jsxs("div", {
|
|
1072
|
-
className: "fallback-links",
|
|
1073
|
-
children: [/* @__PURE__ */ jsx("a", {
|
|
1074
|
-
className: "button",
|
|
1075
|
-
href: searchPath,
|
|
1076
|
-
children: copy.searchLink
|
|
1077
|
-
}), /* @__PURE__ */ jsx("a", {
|
|
1078
|
-
className: "link",
|
|
1079
|
-
href: contactPath,
|
|
1080
|
-
children: copy.contactLink
|
|
1081
|
-
})]
|
|
1082
|
-
})]
|
|
1083
|
-
});
|
|
1084
|
-
/** A refusal that a second try can get past: the message goes back into the field to send again. */
|
|
1085
|
-
const RETRYABLE = /* @__PURE__ */ new Set(["busy", "error"]);
|
|
1086
|
-
function Panel(props) {
|
|
1087
|
-
const { copy, endpoint, open, pending, onSent, onStarted, onClose } = props;
|
|
1088
|
-
const stored = useMemo(() => loadConversation(endpoint), [endpoint]);
|
|
1089
|
-
const [accepted, setAccepted] = useState(stored !== null);
|
|
1090
|
-
const [messages, setMessages] = useState(stored?.messages ?? []);
|
|
1091
|
-
const [draft, setDraft] = useState("");
|
|
1092
|
-
const [streaming, setStreaming] = useState(false);
|
|
1093
|
-
const [unavailable, setUnavailable] = useState(null);
|
|
1094
|
-
const session = useRef(stored?.sessionId ?? null);
|
|
1095
|
-
const abort = useRef(null);
|
|
1096
|
-
const list = useRef(null);
|
|
1097
|
-
const following = useRef(true);
|
|
1098
|
-
const lastRole = useRef(stored?.messages.at(-1)?.role);
|
|
1099
|
-
const start = useRef(null);
|
|
1100
|
-
const composer = useRef(null);
|
|
1101
|
-
const panel = useRef(null);
|
|
1102
|
-
const lastActivity = useRef(stored?.savedAt ?? Date.now());
|
|
1103
|
-
const hasMessages = messages.length > 0;
|
|
1104
|
-
const last = messages.at(-1);
|
|
1105
|
-
const waiting = streaming && !unavailable && (last?.role === "visitor" || !last?.parts.some((part) => part.kind === "text"));
|
|
1106
|
-
const expired = () => Date.now() - lastActivity.current >= SESSION_TTL_S * 1e3;
|
|
1107
|
-
useEffect(() => () => abort.current?.abort(), []);
|
|
1108
|
-
useEffect(() => {
|
|
1109
|
-
const role = messages.at(-1)?.role;
|
|
1110
|
-
const replyStarts = role === "assistant" && lastRole.current === "visitor";
|
|
1111
|
-
lastRole.current = role;
|
|
1112
|
-
const element = list.current;
|
|
1113
|
-
if (!open || !element) return;
|
|
1114
|
-
const turnTop = ([...element.querySelectorAll(":scope > .turn")].at(-1)?.offsetTop ?? 0) - 12;
|
|
1115
|
-
if (replyStarts) element.scrollTop = turnTop;
|
|
1116
|
-
else if (role === "assistant" && following.current) element.scrollTop = Math.max(element.scrollTop, Math.min(element.scrollHeight - element.clientHeight, turnTop));
|
|
1117
|
-
else if (role === "visitor" || following.current) element.scrollTop = element.scrollHeight;
|
|
1118
|
-
}, [
|
|
1119
|
-
messages,
|
|
1120
|
-
open,
|
|
1121
|
-
waiting,
|
|
1122
|
-
unavailable
|
|
1123
|
-
]);
|
|
1124
|
-
useEffect(() => {
|
|
1125
|
-
if (!open) return;
|
|
1126
|
-
const touch = window.matchMedia?.("(pointer: coarse)").matches;
|
|
1127
|
-
if (accepted && hasMessages && touch) panel.current?.focus();
|
|
1128
|
-
else (accepted ? composer : start).current?.focus();
|
|
1129
|
-
}, [accepted, open]);
|
|
1130
|
-
useEffect(() => {
|
|
1131
|
-
onStarted(hasMessages);
|
|
1132
|
-
if (messages.at(-1)?.unconfirmed) return;
|
|
1133
|
-
const kept = messages.filter((message) => !message.unconfirmed);
|
|
1134
|
-
if (kept.length > 0) saveConversation(endpoint, {
|
|
1135
|
-
sessionId: session.current,
|
|
1136
|
-
messages: kept
|
|
1137
|
-
}, lastActivity.current);
|
|
1138
|
-
}, [
|
|
1139
|
-
endpoint,
|
|
1140
|
-
hasMessages,
|
|
1141
|
-
messages,
|
|
1142
|
-
onStarted
|
|
1143
|
-
]);
|
|
1144
|
-
/** Sends one visitor message, typed or tapped (QuickReplies); nothing while a reply streams. */
|
|
1145
|
-
const submit = async (text) => {
|
|
1146
|
-
const message = text.trim().slice(0, MAX_MESSAGE_CHARS);
|
|
1147
|
-
if (!message || streaming) return;
|
|
1148
|
-
const fresh = expired();
|
|
1149
|
-
if (fresh) {
|
|
1150
|
-
session.current = null;
|
|
1151
|
-
clearConversation(endpoint);
|
|
1152
|
-
}
|
|
1153
|
-
setDraft("");
|
|
1154
|
-
const restoreDraft = () => setDraft((current) => current || message);
|
|
1155
|
-
setUnavailable(null);
|
|
1156
|
-
setStreaming(true);
|
|
1157
|
-
setMessages((current) => {
|
|
1158
|
-
const before = fresh ? [] : current;
|
|
1159
|
-
const last = before.at(-1);
|
|
1160
|
-
return [...last?.role === "visitor" && last.unconfirmed && last.parts[0]?.kind === "text" && last.parts[0].text === message ? before.slice(0, -1) : before, {
|
|
1161
|
-
role: "visitor",
|
|
1162
|
-
parts: [{
|
|
1163
|
-
kind: "text",
|
|
1164
|
-
text: message
|
|
1165
|
-
}],
|
|
1166
|
-
unconfirmed: true
|
|
1167
|
-
}];
|
|
1168
|
-
});
|
|
1169
|
-
const sentAt = Date.now();
|
|
1170
|
-
const controller = new AbortController();
|
|
1171
|
-
abort.current = controller;
|
|
1172
|
-
try {
|
|
1173
|
-
await sendMessage(endpoint, {
|
|
1174
|
-
sessionId: session.current,
|
|
1175
|
-
message,
|
|
1176
|
-
locale: props.locale,
|
|
1177
|
-
clientVersion: props.clientVersion
|
|
1178
|
-
}, (chatEvent) => {
|
|
1179
|
-
if (controller.signal.aborted) return;
|
|
1180
|
-
if (chatEvent.type === "session") session.current = chatEvent.sessionId;
|
|
1181
|
-
else if (chatEvent.type === "text") setMessages((current) => appendToReply(current, {
|
|
1182
|
-
kind: "text",
|
|
1183
|
-
text: chatEvent.delta
|
|
1184
|
-
}));
|
|
1185
|
-
else if (chatEvent.type === "ui") setMessages((current) => appendToReply(current, {
|
|
1186
|
-
kind: "ui",
|
|
1187
|
-
spec: chatEvent.spec
|
|
1188
|
-
}));
|
|
1189
|
-
else if (chatEvent.type === "unavailable") {
|
|
1190
|
-
setUnavailable(chatEvent.reason);
|
|
1191
|
-
if (RETRYABLE.has(chatEvent.reason)) restoreDraft();
|
|
1192
|
-
} else if (chatEvent.type === "done") {
|
|
1193
|
-
lastActivity.current = sentAt;
|
|
1194
|
-
setMessages(confirmed);
|
|
1195
|
-
}
|
|
1196
|
-
}, controller.signal);
|
|
1197
|
-
} catch {
|
|
1198
|
-
if (!controller.signal.aborted) {
|
|
1199
|
-
setUnavailable("error");
|
|
1200
|
-
restoreDraft();
|
|
1201
|
-
}
|
|
1202
|
-
} finally {
|
|
1203
|
-
if (abort.current === controller) setStreaming(false);
|
|
1204
|
-
}
|
|
1205
|
-
};
|
|
1206
|
-
useEffect(() => {
|
|
1207
|
-
if (!open || !accepted || pending === null) return;
|
|
1208
|
-
onSent();
|
|
1209
|
-
submit(pending);
|
|
1210
|
-
}, [
|
|
1211
|
-
open,
|
|
1212
|
-
accepted,
|
|
1213
|
-
pending,
|
|
1214
|
-
onSent
|
|
1215
|
-
]);
|
|
1216
|
-
/** A new conversation: the service starts a new session at the next message. */
|
|
1217
|
-
const restart = () => {
|
|
1218
|
-
abort.current?.abort();
|
|
1219
|
-
abort.current = null;
|
|
1220
|
-
setStreaming(false);
|
|
1221
|
-
setMessages([]);
|
|
1222
|
-
setUnavailable(null);
|
|
1223
|
-
session.current = null;
|
|
1224
|
-
clearConversation(endpoint);
|
|
1225
|
-
composer.current?.focus();
|
|
1226
|
-
};
|
|
1227
|
-
useEffect(() => {
|
|
1228
|
-
if (!open) return;
|
|
1229
|
-
return lockPageScroll();
|
|
1230
|
-
}, [open]);
|
|
1231
|
-
useEffect(() => {
|
|
1232
|
-
if (!open) return;
|
|
1233
|
-
document.addEventListener("astro:before-swap", onClose);
|
|
1234
|
-
return () => document.removeEventListener("astro:before-swap", onClose);
|
|
1235
|
-
}, [open, onClose]);
|
|
1236
|
-
useEffect(() => {
|
|
1237
|
-
if (open && hasMessages && !streaming && expired()) restart();
|
|
1238
|
-
}, [open]);
|
|
1239
|
-
/** Tab stays in the panel while it is open: the page behind the scrim is out of reach. */
|
|
1240
|
-
const trapTab = (event) => {
|
|
1241
|
-
const element = panel.current;
|
|
1242
|
-
if (event.key !== "Tab" || !element) return;
|
|
1243
|
-
const focusable = [...element.querySelectorAll("a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled])")];
|
|
1244
|
-
const active = element.getRootNode().activeElement;
|
|
1245
|
-
const edge = event.shiftKey ? focusable[0] : focusable.at(-1);
|
|
1246
|
-
const outside = !element.contains(active) || event.shiftKey && active === element;
|
|
1247
|
-
if (focusable.length === 0 || active === edge || outside) {
|
|
1248
|
-
event.preventDefault();
|
|
1249
|
-
(event.shiftKey ? focusable.at(-1) : focusable[0])?.focus();
|
|
1250
|
-
}
|
|
1251
|
-
};
|
|
1252
|
-
const send = (event) => {
|
|
1253
|
-
event.preventDefault();
|
|
1254
|
-
submit(draft);
|
|
1255
|
-
};
|
|
1256
|
-
return /* @__PURE__ */ jsxs(ChatContext.Provider, {
|
|
1257
|
-
value: {
|
|
1258
|
-
...props,
|
|
1259
|
-
send: (text) => void submit(text),
|
|
1260
|
-
busy: streaming
|
|
1261
|
-
},
|
|
1262
|
-
children: [/* @__PURE__ */ jsx("div", {
|
|
1263
|
-
className: "scrim",
|
|
1264
|
-
hidden: !open,
|
|
1265
|
-
"aria-hidden": "true",
|
|
1266
|
-
onClick: onClose
|
|
1267
|
-
}), /* @__PURE__ */ jsxs("section", {
|
|
1268
|
-
className: "panel",
|
|
1269
|
-
role: "dialog",
|
|
1270
|
-
"aria-modal": "true",
|
|
1271
|
-
"aria-label": copy.title,
|
|
1272
|
-
ref: panel,
|
|
1273
|
-
tabIndex: -1,
|
|
1274
|
-
hidden: !open,
|
|
1275
|
-
inert: !open,
|
|
1276
|
-
onKeyDown: (event) => {
|
|
1277
|
-
if (event.key === "Escape") onClose();
|
|
1278
|
-
trapTab(event);
|
|
1279
|
-
},
|
|
1280
|
-
children: [
|
|
1281
|
-
/* @__PURE__ */ jsxs("header", {
|
|
1282
|
-
className: "header",
|
|
1283
|
-
children: [
|
|
1284
|
-
/* @__PURE__ */ jsx("h2", {
|
|
1285
|
-
className: "title",
|
|
1286
|
-
children: copy.title
|
|
1287
|
-
}),
|
|
1288
|
-
hasMessages && /* @__PURE__ */ jsx("button", {
|
|
1289
|
-
type: "button",
|
|
1290
|
-
className: "icon-button restart",
|
|
1291
|
-
"aria-label": copy.newConversation,
|
|
1292
|
-
title: copy.newConversation,
|
|
1293
|
-
onClick: restart,
|
|
1294
|
-
children: /* @__PURE__ */ jsx(Restart, {})
|
|
1295
|
-
}),
|
|
1296
|
-
/* @__PURE__ */ jsx("button", {
|
|
1297
|
-
type: "button",
|
|
1298
|
-
className: "icon-button close",
|
|
1299
|
-
"aria-label": copy.close,
|
|
1300
|
-
onClick: onClose,
|
|
1301
|
-
children: /* @__PURE__ */ jsx(Cross, {})
|
|
1302
|
-
})
|
|
1303
|
-
]
|
|
1304
|
-
}),
|
|
1305
|
-
/* @__PURE__ */ jsx("p", {
|
|
1306
|
-
className: "notice",
|
|
1307
|
-
children: copy.notice
|
|
1308
|
-
}),
|
|
1309
|
-
accepted ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1310
|
-
/* @__PURE__ */ jsxs("div", {
|
|
1311
|
-
className: "messages",
|
|
1312
|
-
role: "log",
|
|
1313
|
-
"aria-busy": streaming,
|
|
1314
|
-
ref: list,
|
|
1315
|
-
onScroll: (event) => {
|
|
1316
|
-
const element = event.currentTarget;
|
|
1317
|
-
following.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
|
|
1318
|
-
},
|
|
1319
|
-
children: [
|
|
1320
|
-
/* @__PURE__ */ jsx("div", {
|
|
1321
|
-
className: "message",
|
|
1322
|
-
"data-role": "assistant",
|
|
1323
|
-
children: copy.greeting
|
|
1324
|
-
}),
|
|
1325
|
-
messages.map((message, index) => /* @__PURE__ */ jsxs("div", {
|
|
1326
|
-
className: message.role === "assistant" ? "ui" : "turn",
|
|
1327
|
-
children: [/* @__PURE__ */ jsxs("span", {
|
|
1328
|
-
className: "sr-only",
|
|
1329
|
-
children: [message.role === "assistant" ? copy.title : copy.yourMessage, ":"]
|
|
1330
|
-
}), message.parts.map((part, partIndex) => part.kind === "text" ? /* @__PURE__ */ jsx("div", {
|
|
1331
|
-
className: "message",
|
|
1332
|
-
"data-role": message.role,
|
|
1333
|
-
children: message.role === "assistant" ? /* @__PURE__ */ jsx(RichText, { text: part.text }) : part.text
|
|
1334
|
-
}, partIndex) : /* @__PURE__ */ jsx(SpecView, { spec: part.spec }, partIndex))]
|
|
1335
|
-
}, index)),
|
|
1336
|
-
unavailable && /* @__PURE__ */ jsx(Fallback, {
|
|
1337
|
-
reason: unavailable,
|
|
1338
|
-
copy,
|
|
1339
|
-
searchPath: props.searchPath,
|
|
1340
|
-
contactPath: props.contactPath
|
|
1341
|
-
}),
|
|
1342
|
-
waiting && /* @__PURE__ */ jsx(Thinking, { label: copy.thinking })
|
|
1343
|
-
]
|
|
1344
|
-
}),
|
|
1345
|
-
/* @__PURE__ */ jsx("p", {
|
|
1346
|
-
className: "sr-only",
|
|
1347
|
-
role: "status",
|
|
1348
|
-
children: waiting ? copy.thinking : ""
|
|
1349
|
-
}),
|
|
1350
|
-
/* @__PURE__ */ jsxs("div", {
|
|
1351
|
-
className: "dock",
|
|
1352
|
-
children: [!hasMessages && copy.suggestions.length > 0 && /* @__PURE__ */ jsx("div", {
|
|
1353
|
-
className: "suggestions",
|
|
1354
|
-
children: copy.suggestions.map((suggestion) => /* @__PURE__ */ jsxs("button", {
|
|
1355
|
-
type: "button",
|
|
1356
|
-
className: "suggestion",
|
|
1357
|
-
onClick: () => void submit(suggestion),
|
|
1358
|
-
children: [/* @__PURE__ */ jsx(TopicIcon, { text: suggestion }), suggestion]
|
|
1359
|
-
}, suggestion))
|
|
1360
|
-
}), /* @__PURE__ */ jsxs("form", {
|
|
1361
|
-
className: "composer",
|
|
1362
|
-
onSubmit: send,
|
|
1363
|
-
children: [/* @__PURE__ */ jsx("textarea", {
|
|
1364
|
-
ref: composer,
|
|
1365
|
-
rows: 1,
|
|
1366
|
-
value: draft,
|
|
1367
|
-
maxLength: MAX_MESSAGE_CHARS,
|
|
1368
|
-
placeholder: copy.placeholder,
|
|
1369
|
-
"aria-label": copy.placeholder,
|
|
1370
|
-
onChange: (event) => setDraft(event.target.value),
|
|
1371
|
-
onKeyDown: (event) => {
|
|
1372
|
-
if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
|
|
1373
|
-
event.preventDefault();
|
|
1374
|
-
event.currentTarget.form?.requestSubmit();
|
|
1375
|
-
}
|
|
1376
|
-
}), /* @__PURE__ */ jsx("button", {
|
|
1377
|
-
type: "submit",
|
|
1378
|
-
className: "send",
|
|
1379
|
-
"aria-label": copy.send,
|
|
1380
|
-
disabled: streaming || !draft.trim(),
|
|
1381
|
-
children: /* @__PURE__ */ jsx(Arrow, {})
|
|
1382
|
-
})]
|
|
1383
|
-
})]
|
|
1384
|
-
})
|
|
1385
|
-
] }) : /* @__PURE__ */ jsx(Consent, {
|
|
1386
|
-
copy,
|
|
1387
|
-
pending,
|
|
1388
|
-
onAccept: () => setAccepted(true),
|
|
1389
|
-
buttonRef: start
|
|
1390
|
-
})
|
|
1391
|
-
]
|
|
1392
|
-
})]
|
|
1393
|
-
});
|
|
1394
|
-
}
|
|
1395
|
-
//#endregion
|
|
1396
|
-
export { Panel as default };
|