@v-office/website-chat 1.0.1 → 1.1.1

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 CHANGED
@@ -28,16 +28,21 @@ Render `WebsiteChat` once in the layout as a client island; see `instructions/ve
28
28
  | `locale` | yes | `"de"` or `"en"` |
29
29
  | `searchPath` | yes | the site's search, for a rental without a page and when the chat is down |
30
30
  | `contactPath` | yes | the site's contact page |
31
- | `theme` | no | `classic` (default), `soft` or `minimal` |
32
- | `colors` | no | `{ primary, secondary }`, any CSS colour (buttons; visitor messages) |
31
+ | `theme` | no | `classic` (default), `soft` or `minimal`: shadows or borders, how round |
32
+ | `colors` | no | `{ primary, secondary, accent }`, any CSS colour, over the site's tokens |
33
33
  | `position` | no | the entry pill and the panel on the `right` (default) or `left` |
34
34
  | `copy` | no | overrides of single fields of `DEFAULT_COPY` (strings, `suggestions`) |
35
35
 
36
36
  The chat stays hidden until an agent switches it on for the website in AI Studio.
37
37
 
38
+ It takes its colours and corners from the site's shadcn tokens (`--primary`,
39
+ `--primary-foreground`, `--background`, `--foreground`, `--muted`, `--muted-foreground`, `--border`,
40
+ `--radius`), dark mode included, and its own values where a site has none. A site whose corners
41
+ are square (`--radius: 0`) gets square buttons too.
42
+
38
43
  A site with a sticky header sets `--chat-top` (its height) on any element around the widget, such as
39
44
  `:root`: the dimmed page and the panel start below it. `--chat-width` widens the panel on wide screens
40
- (default `600px`).
45
+ (default `700px`).
41
46
 
42
47
  The service places these components in replies: `RentalCard` (photo gallery, rating, the wishes the
43
48
  search that found it applied), `RentalCarousel`, `Compare`, `StayQuote`, `PriceBreakdown`,
@@ -137,16 +137,23 @@ const versionOf = (version) => {
137
137
  Number(match[3])
138
138
  ] : null;
139
139
  };
140
- /** See {@link ComponentsFor}. */
141
- const componentsFor = (clientVersion) => {
140
+ /** Whether a widget of `clientVersion` is `since` or newer within the same major; an unreadable
141
+ * version is neither. */
142
+ const isAtLeast = (clientVersion, since) => {
142
143
  const client = versionOf(clientVersion);
143
- if (!client) return [];
144
- return PLACEABLE_COMPONENTS.filter((name) => {
145
- const since = versionOf(COMPONENT_SINCE[name]);
146
- if (since[0] !== client[0]) return false;
147
- return since[1] < client[1] || since[1] === client[1] && since[2] <= client[2];
148
- });
144
+ const from = versionOf(since);
145
+ if (!client || from[0] !== client[0]) return false;
146
+ return from[1] < client[1] || from[1] === client[1] && from[2] <= client[2];
149
147
  };
148
+ /** See {@link ComponentsFor}. */
149
+ const componentsFor = (clientVersion) => PLACEABLE_COMPONENTS.filter((name) => isAtLeast(clientVersion, COMPONENT_SINCE[name]));
150
+ /** Whether the widget shows the assistant's `**bold**` as bold text (1.1.0 and later); an older
151
+ * one would show the asterisks, so the service asks for none there. */
152
+ const rendersBold = (clientVersion) => isAtLeast(clientVersion, "1.1.0");
153
+ /** Whether the widget's booking button opens the rental's booking page with the rate and extras the
154
+ * visitor chose in the chat (1.1.0 and later); an older one opens the rental page and loses them, so
155
+ * the service puts no stay together there. */
156
+ const handsOverStays = (clientVersion) => isAtLeast(clientVersion, "1.1.0");
150
157
  /** See {@link ParseItem}. */
151
158
  const parseItem = (item, allowed) => {
152
159
  if (typeof item !== "object" || item === null) return null;
@@ -180,4 +187,4 @@ const chatSpec = (items, state) => {
180
187
  };
181
188
  };
182
189
  //#endregion
183
- export { componentsFor as a, statePath as c, chatSpec as i, PLACEABLE_COMPONENTS as n, parseItem as o, chatCatalog as r, stateKey as s, COMPONENT_SINCE as t };
190
+ export { componentsFor as a, rendersBold as c, chatSpec as i, stateKey as l, PLACEABLE_COMPONENTS as n, handsOverStays as o, chatCatalog as r, parseItem as s, COMPONENT_SINCE as t, statePath as u };
package/dist/catalog.d.ts CHANGED
@@ -51,6 +51,15 @@ interface ChatStay {
51
51
  readonly label: string;
52
52
  readonly amount: string;
53
53
  }[];
54
+ /** The rate the visitor chose in the chat (`?policy=` on the way to booking); absent for the
55
+ * PMS's pre-selection. */
56
+ readonly policyId?: string | null;
57
+ /** The extras the visitor chose in the chat, by the PMS's service id, as the quote priced them
58
+ * (`?service=<id>:<count>` on the way to booking). */
59
+ readonly services?: readonly {
60
+ readonly id: string;
61
+ readonly count: number;
62
+ }[];
54
63
  }
55
64
  /** A day of a rental's calendar: bookable, taken, or outside what can be booked (past, closed). */
56
65
  type ChatDayStatus = "free" | "booked" | "closed";
@@ -109,6 +118,9 @@ interface ChatState {
109
118
  readonly stays: Readonly<Record<string, ChatStay>>;
110
119
  /** A rental's page on this website in the visitor's language; absent without one. */
111
120
  readonly pages: Readonly<Record<string, string>>;
121
+ /** A rental's booking page on this website, where a stay is booked with everything the chat put
122
+ * together; absent without one (the stay then goes to the rental page). */
123
+ readonly bookingPages?: Readonly<Record<string, string>>;
112
124
  /** A rental's calendar, by rental id; only for rentals whose calendar was asked for. */
113
125
  readonly availability: Readonly<Record<string, ChatCalendar>>;
114
126
  /** A rental's price calendar, by rental id (PriceCalendar). */
@@ -295,6 +307,13 @@ type ParseItem = (item: unknown, allowed: readonly PlaceableComponent[]) => Chat
295
307
  type ChatSpec = (items: readonly ChatItem[], state: ChatState) => Spec;
296
308
  /** See {@link ComponentsFor}. */
297
309
  export declare const componentsFor: ComponentsFor;
310
+ /** Whether the widget shows the assistant's `**bold**` as bold text (1.1.0 and later); an older
311
+ * one would show the asterisks, so the service asks for none there. */
312
+ export declare const rendersBold: (clientVersion: string) => boolean;
313
+ /** Whether the widget's booking button opens the rental's booking page with the rate and extras the
314
+ * visitor chose in the chat (1.1.0 and later); an older one opens the rental page and loses them, so
315
+ * the service puts no stay together there. */
316
+ export declare const handsOverStays: (clientVersion: string) => boolean;
298
317
  /** See {@link ParseItem}. */
299
318
  export declare const parseItem: ParseItem;
300
319
  /** See {@link ChatSpec}. */
package/dist/catalog.js CHANGED
@@ -1,3 +1,3 @@
1
1
  import { i as SESSION_TTL_S, n as CHAT_PATH, r as MAX_MESSAGE_CHARS, t as CHAT_LOCALES } from "./protocol-BuNqZAMt.js";
2
- import { a as componentsFor, c as statePath, i as chatSpec, n as PLACEABLE_COMPONENTS, o as parseItem, r as chatCatalog, s as stateKey, t as COMPONENT_SINCE } from "./catalog-RXKmEmMW.js";
3
- export { CHAT_LOCALES, CHAT_PATH, COMPONENT_SINCE, MAX_MESSAGE_CHARS, PLACEABLE_COMPONENTS, SESSION_TTL_S, chatCatalog, chatSpec, componentsFor, parseItem, stateKey, statePath };
2
+ import { a as componentsFor, c as rendersBold, i as chatSpec, l as stateKey, n as PLACEABLE_COMPONENTS, o as handsOverStays, r as chatCatalog, s as parseItem, t as COMPONENT_SINCE, u as statePath } from "./catalog-y3xQ2jXW.js";
3
+ export { CHAT_LOCALES, CHAT_PATH, COMPONENT_SINCE, MAX_MESSAGE_CHARS, PLACEABLE_COMPONENTS, SESSION_TTL_S, chatCatalog, chatSpec, componentsFor, handsOverStays, parseItem, rendersBold, stateKey, statePath };
@@ -0,0 +1,243 @@
1
+ import { n as CHAT_PATH } from "./protocol-BuNqZAMt.js";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/client.ts
4
+ const url = (endpoint) => `${endpoint.replace(/\/+$/, "")}${CHAT_PATH}`;
5
+ /** See {@link ChatAvailable}. */
6
+ const chatAvailable = async (endpoint, signal) => {
7
+ if (!endpoint) return false;
8
+ try {
9
+ const res = await fetch(url(endpoint), signal ? { signal } : {});
10
+ if (!res.ok) return false;
11
+ return (await res.json()).available === true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ };
16
+ /** See {@link ParseEvents}. Only `data:` lines matter: the event's type travels in its JSON. */
17
+ const parseEvents = (buffer) => {
18
+ const blocks = buffer.split("\n\n");
19
+ const rest = blocks.pop() ?? "";
20
+ const events = [];
21
+ for (const block of blocks) {
22
+ const data = block.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
23
+ if (!data) continue;
24
+ try {
25
+ events.push(JSON.parse(data));
26
+ } catch {}
27
+ }
28
+ return {
29
+ events,
30
+ rest
31
+ };
32
+ };
33
+ /** See {@link SendMessage}. */
34
+ const sendMessage = async (endpoint, request, onEvent, signal) => {
35
+ const res = await fetch(url(endpoint), {
36
+ method: "POST",
37
+ headers: {
38
+ "content-type": "application/json",
39
+ accept: "text/event-stream"
40
+ },
41
+ body: JSON.stringify(request),
42
+ ...signal ? { signal } : {}
43
+ });
44
+ if (!res.ok || !res.body) {
45
+ onEvent({
46
+ type: "unavailable",
47
+ reason: res.status === 429 ? "limit" : res.status === 409 ? "busy" : "error"
48
+ });
49
+ return;
50
+ }
51
+ const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
52
+ let buffer = "";
53
+ let ended = false;
54
+ for (;;) {
55
+ const { done, value } = await reader.read();
56
+ if (done) break;
57
+ const parsed = parseEvents(buffer + value);
58
+ buffer = parsed.rest;
59
+ for (const event of parsed.events) {
60
+ if (event.type === "done" || event.type === "unavailable") ended = true;
61
+ onEvent(event);
62
+ }
63
+ }
64
+ if (!ended) onEvent({
65
+ type: "unavailable",
66
+ reason: "error"
67
+ });
68
+ };
69
+ //#endregion
70
+ //#region src/conversation.ts
71
+ const keyOf = (endpoint) => `v-office-chat:${endpoint}`;
72
+ /** sessionStorage throws where a browser forbids it (some private modes): the chat then lives in the
73
+ * page only, as before. */
74
+ const storage = () => {
75
+ try {
76
+ return globalThis.sessionStorage ?? null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ };
81
+ /** See {@link LoadConversation}. */
82
+ const loadConversation = (endpoint, now = Date.now()) => {
83
+ try {
84
+ const raw = storage()?.getItem(keyOf(endpoint));
85
+ if (!raw) return null;
86
+ const stored = JSON.parse(raw);
87
+ if (!Array.isArray(stored.messages) || stored.messages.length === 0) return null;
88
+ if (!(now - stored.savedAt < 72e5)) {
89
+ storage()?.removeItem(keyOf(endpoint));
90
+ return null;
91
+ }
92
+ return {
93
+ sessionId: stored.sessionId ?? null,
94
+ messages: stored.messages,
95
+ savedAt: stored.savedAt
96
+ };
97
+ } catch {
98
+ return null;
99
+ }
100
+ };
101
+ /** See {@link SaveConversation}. */
102
+ const saveConversation = (endpoint, conversation, savedAt = Date.now()) => {
103
+ try {
104
+ storage()?.setItem(keyOf(endpoint), JSON.stringify({
105
+ ...conversation,
106
+ savedAt
107
+ }));
108
+ } catch {
109
+ clearConversation(endpoint);
110
+ }
111
+ };
112
+ /** See {@link ClearConversation}. */
113
+ const clearConversation = (endpoint) => {
114
+ try {
115
+ storage()?.removeItem(keyOf(endpoint));
116
+ } catch {}
117
+ };
118
+ //#endregion
119
+ //#region src/icons.tsx
120
+ const Svg = ({ children, size = 16 }) => /* @__PURE__ */ jsx("svg", {
121
+ width: size,
122
+ height: size,
123
+ viewBox: "0 0 24 24",
124
+ fill: "none",
125
+ stroke: "currentColor",
126
+ strokeWidth: 2.2,
127
+ strokeLinecap: "round",
128
+ strokeLinejoin: "round",
129
+ "aria-hidden": "true",
130
+ focusable: "false",
131
+ children
132
+ });
133
+ /** The chat's mark, two sparkles in the accent colour's circle. */
134
+ const Spark = () => /* @__PURE__ */ jsx("span", {
135
+ className: "spark",
136
+ children: /* @__PURE__ */ jsxs(Svg, {
137
+ size: 18,
138
+ children: [/* @__PURE__ */ jsx("path", { d: "M10 4c.5 4 2 5.5 6 6-4 .5-5.5 2-6 6-.5-4-2-5.5-6-6 4-.5 5.5-2 6-6Z" }), /* @__PURE__ */ jsx("path", { d: "M18 14c.3 2 1 2.7 3 3-2 .3-2.7 1-3 3-.3-2-1-2.7-3-3 2-.3 2.7-1 3-3Z" })]
139
+ })
140
+ });
141
+ const Arrow = () => /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 12h14M13 6l6 6-6 6" }) });
142
+ const Restart = () => /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5" }) });
143
+ const Cross = () => /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M6 6l12 12M18 6L6 18" }) });
144
+ const Chevron = ({ side }) => /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: side === "prev" ? "M15 18l-6-6 6-6" : "M9 6l6 6-6 6" }) });
145
+ const Check = () => /* @__PURE__ */ jsx(Svg, {
146
+ size: 14,
147
+ children: /* @__PURE__ */ jsx("path", { d: "M5 12.5l4.5 4.5L19 7.5" })
148
+ });
149
+ /** In a photo's place on a card without photos. */
150
+ const House = () => /* @__PURE__ */ jsx(Svg, {
151
+ size: 32,
152
+ children: /* @__PURE__ */ jsx("path", { d: "M3 10.5 12 3l9 7.5M5 9v11h14V9M10 20v-6h4v6" })
153
+ });
154
+ /** What a suggestion is about, read from its words (German and English), for its icon. */
155
+ const TOPICS = [
156
+ {
157
+ topic: "date",
158
+ words: /\bwann\b|termin|datum|anreise|\bwhen\b|\bdates?\b/
159
+ },
160
+ {
161
+ topic: "book",
162
+ words: /buch|\bbook/
163
+ },
164
+ {
165
+ topic: "price",
166
+ words: /preis|kost|günstig|teuer|budget|€|price|cost|cheap/
167
+ },
168
+ {
169
+ topic: "pet",
170
+ words: /hund|katze|haustier|\b(?:dogs?|cats?|pets?)\b/
171
+ },
172
+ {
173
+ topic: "people",
174
+ words: /famili|kinder|zu zweit|personen|erwachsene|gruppe|family|kids|people|adults|couple/
175
+ },
176
+ {
177
+ topic: "date",
178
+ words: /wann|termin|datum|woche|nächte|nacht|ferien|silvester|ostern|sommer|herbst|winter|frühling|januar|februar|märz|april|mai|juni|juli|august|september|oktober|november|dezember|when|date|week|night|holiday/
179
+ },
180
+ {
181
+ topic: "place",
182
+ words: /strand|lage|meer|see|wo |nähe|entfern|beach|where|sea|location/
183
+ }
184
+ ];
185
+ const TOPIC_PATHS = {
186
+ book: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("rect", {
187
+ x: "5",
188
+ y: "10.5",
189
+ width: "14",
190
+ height: "10",
191
+ rx: "2"
192
+ }), /* @__PURE__ */ jsx("path", { d: "M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3" })] }),
193
+ price: /* @__PURE__ */ jsx("path", { d: "M17 6.5A6.5 6.5 0 1 0 17 17.5M4 10.5h9M4 13.5h9" }),
194
+ pet: /* @__PURE__ */ jsxs(Fragment, { children: [
195
+ /* @__PURE__ */ jsx("circle", {
196
+ cx: "6",
197
+ cy: "10",
198
+ r: "1.6"
199
+ }),
200
+ /* @__PURE__ */ jsx("circle", {
201
+ cx: "10",
202
+ cy: "6",
203
+ r: "1.6"
204
+ }),
205
+ /* @__PURE__ */ jsx("circle", {
206
+ cx: "14",
207
+ cy: "6",
208
+ r: "1.6"
209
+ }),
210
+ /* @__PURE__ */ jsx("circle", {
211
+ cx: "18",
212
+ cy: "10",
213
+ r: "1.6"
214
+ }),
215
+ /* @__PURE__ */ jsx("path", { d: "M12 12c-3 0-5 3-5 5.2 0 1.4 1.3 2 2.6 1.6 1.5-.5 3.3-.5 4.8 0 1.3.4 2.6-.2 2.6-1.6C17 15 15 12 12 12Z" })
216
+ ] }),
217
+ people: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("circle", {
218
+ cx: "9",
219
+ cy: "8",
220
+ r: "3"
221
+ }), /* @__PURE__ */ jsx("path", { d: "M3.5 19c.6-3 2.8-5 5.5-5s4.9 2 5.5 5M15.5 5.5a3 3 0 0 1 0 5.6M17.5 14.3c1.6.7 2.7 2.4 3 4.7" })] }),
222
+ date: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("rect", {
223
+ x: "4",
224
+ y: "5.5",
225
+ width: "16",
226
+ height: "14.5",
227
+ rx: "2"
228
+ }), /* @__PURE__ */ jsx("path", { d: "M4 10h16M8.5 3.5v4M15.5 3.5v4" })] }),
229
+ place: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("path", { d: "M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11Z" }), /* @__PURE__ */ jsx("circle", {
230
+ cx: "12",
231
+ cy: "10",
232
+ r: "2.3"
233
+ })] }),
234
+ other: /* @__PURE__ */ jsx("path", { d: "M5 5.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4.5 3.5v-3.5H5A1.5 1.5 0 0 1 3.5 15V7A1.5 1.5 0 0 1 5 5.5Z" })
235
+ };
236
+ /** A suggestion's icon: the first topic its words name, else a speech bubble. */
237
+ const TopicIcon = ({ text }) => {
238
+ const words = text.toLowerCase();
239
+ const topic = TOPICS.find((candidate) => candidate.words.test(words))?.topic ?? "other";
240
+ return /* @__PURE__ */ jsx(Svg, { children: TOPIC_PATHS[topic] });
241
+ };
242
+ //#endregion
243
+ export { House as a, TopicIcon as c, saveConversation as d, chatAvailable as f, Cross as i, clearConversation as l, Check as n, Restart as o, sendMessage as p, Chevron as r, Spark as s, Arrow as t, loadConversation as u };
package/dist/index.d.ts CHANGED
@@ -30,6 +30,16 @@ interface ChatCopy {
30
30
  readonly contactLink: string;
31
31
  readonly bookStay: string;
32
32
  readonly viewRental: string;
33
+ /** A card's short button: book its stay, or see the rental without one. */
34
+ readonly book: string;
35
+ readonly view: string;
36
+ /** @deprecated Not shown since 1.1.1: a card lists the wishes it matches instead. Kept so a site's
37
+ * `copy` that sets it still type-checks. */
38
+ readonly matchBadge: string;
39
+ /** A stay's length on a card; `{n}` is the count. */
40
+ readonly nights: Readonly<Partial<Record<Intl.LDMLPluralRule, string>>> & {
41
+ readonly other: string;
42
+ };
33
43
  readonly total: string;
34
44
  readonly notBookable: string;
35
45
  readonly photo: string;
@@ -76,15 +86,20 @@ interface ChatCopy {
76
86
  export declare const DEFAULT_COPY: Readonly<Record<ChatLocale, ChatCopy>>;
77
87
  //#endregion
78
88
  //#region src/theme.d.ts
79
- /** The curated looks over one layout: `classic` (soft shadows, medium corners), `soft` (round corners,
80
- * lighter shadows), `minimal` (borders instead of shadows, small corners). */
89
+ /** The curated looks over one layout, each relative to the site's corner radius: `classic` (soft
90
+ * shadows), `soft` (rounder corners, lighter shadows), `minimal` (borders instead of shadows, tighter
91
+ * corners). */
81
92
  export declare const CHAT_THEMES: readonly ["classic", "soft", "minimal"];
82
93
  type ChatTheme = (typeof CHAT_THEMES)[number];
83
- /** A site's colours, any CSS colour. `primary` fills buttons, the send button and the chat's mark, and
84
- * runs around the entry pill; `secondary` tints the visitor's messages and the suggestions. */
94
+ /** Colours for the chat, any CSS colour, over the ones it takes from the site's tokens. `primary`
95
+ * fills buttons, the send button and the chat's mark, and runs around the entry pill; `secondary`
96
+ * tints the visitor's messages and the suggestions. */
85
97
  interface ChatColors {
86
98
  readonly primary?: string;
87
99
  readonly secondary?: string;
100
+ /** The small marks: the chat's name and sparkle, a card's place, the icons, the ring round the
101
+ * entry pill. The primary when unset. */
102
+ readonly accent?: string;
88
103
  }
89
104
  //#endregion
90
105
  //#region src/WebsiteChat.d.ts