@doany-ai/inbox 0.0.0-stage → 0.1.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.
@@ -0,0 +1,442 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { cn } from "@doany-ai/app-core";
3
+ import { FieldError } from "@doany-ai/app-core/form";
4
+ import { Button } from "@doany-ai/app-core/ui/button";
5
+ import { ArrowLeft, ArrowUp, CalendarDays, ChevronDown, Home, Loader2, Mail, MessageCircle, MessageSquareText, MoreHorizontal, Phone, Send, X } from "lucide-react";
6
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
7
+ import { useLocation, useNavigate } from "react-router-dom";
8
+ import { EMAIL_MAX, EMAIL_PATTERN, MESSAGE_MAX, backAtParts, hashWithoutLinkToken, isHiddenPath, linkTokenIn, relativeTime } from "../format.js";
9
+ import { useInbox } from "../hooks.js";
10
+ import { aiNoticeText, eventText, inboxLabels, privacyNoticeParts, saysTheWait, suggestedQuestionTexts } from "../labels.js";
11
+ import { PRIVACY_DISMISSED_KEY, sessionMark, setSessionMark } from "../storage.js";
12
+ import { Avatar, EventLine, HomeCard, MessageRow, OwnBubble, TheirBubble, TypingDots, usePageVisible } from "./parts.js";
13
+ /** The times under the bubbles are worked out again this often. */
14
+ const CLOCK_MS = 30000;
15
+ function browserLanguage() {
16
+ return typeof navigator !== "undefined" && navigator.language ? navigator.language : undefined;
17
+ }
18
+ function problemText(problem, labels) {
19
+ switch (problem) {
20
+ case "contact_limit":
21
+ return labels.contactLimit;
22
+ case "chat_limit":
23
+ return labels.tryLater;
24
+ case "rate_limited":
25
+ return labels.rateLimited;
26
+ case "not_published":
27
+ return labels.notPublished;
28
+ case "off":
29
+ return labels.off;
30
+ case "closed":
31
+ return labels.closed;
32
+ case "failed":
33
+ return labels.sendFailed;
34
+ default:
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * What this page tells the platform it can show with each message: a Book
40
+ * online row (`book_online`, when the site has a booking page), so the
41
+ * receptionist sends clients there instead of handing them to the team; and
42
+ * offer cards (`offer_booking`, when the site draws its booking flow).
43
+ */
44
+ function pageCapabilities(bookHref, renderBooking) {
45
+ const found = [...(bookHref ? ["book_online"] : []), ...(renderBooking ? ["offer_booking"] : [])];
46
+ return found.length ? found : undefined;
47
+ }
48
+ /** A bubble from the business's side (not an event, not the client's). */
49
+ const isTheirs = (message) => message.kind !== "event" && message.author !== "client" && (message.kind === "offer" || Boolean(message.text));
50
+ /** "5 min. ago", or the label for under a minute; empty when the time cannot be read. */
51
+ function timeAgo(iso, now, labels, locale) {
52
+ return relativeTime(iso, now, locale) ?? labels.justNow;
53
+ }
54
+ /**
55
+ * The byline under each group of bubbles from the business's side, by the id
56
+ * of the group's last message: "Annie · AI receptionist · 5 min. ago" for the
57
+ * receptionist, "Jo · 5 min. ago" for a person on the team.
58
+ */
59
+ function bylines(messages, labels, receptionist, now, locale) {
60
+ const found = new Map();
61
+ messages.forEach((message, index) => {
62
+ if (!isTheirs(message))
63
+ return;
64
+ const next = messages[index + 1];
65
+ if (next && isTheirs(next) && next.author === message.author && next.author_name === message.author_name)
66
+ return;
67
+ const who = message.author === "receptionist" ? [message.author_name || receptionist, labels.aiReceptionist] : [message.author_name || labels.team];
68
+ found.set(message.id, [...who, timeAgo(message.occurred_at, now, labels, locale)].filter(Boolean).join(" · "));
69
+ });
70
+ return found;
71
+ }
72
+ /**
73
+ * The chat button and its panel, for a site. Mount it once, inside the
74
+ * router and outside the sign-in gate, beside `<Toaster />`. It reads the
75
+ * business's settings itself and shows nothing where the business has no
76
+ * button, on its hidden paths, or before the settings are read.
77
+ */
78
+ export function InboxWidget({ bookHref, renderBooking, privacyHref, labels: given, locale, className }) {
79
+ const labels = useMemo(() => inboxLabels(given), [given]);
80
+ const location = useLocation();
81
+ const navigate = useNavigate();
82
+ const [open, setOpen] = useState(false);
83
+ // What is typed in the chat's box outlives the panel: closing it, or the
84
+ // business turning the button off for a while, loses nothing.
85
+ const [composer, setComposer] = useState("");
86
+ const wakeRef = useRef(() => { });
87
+ const launcherRef = useRef(null);
88
+ const onShow = useCallback(() => wakeRef.current(), []);
89
+ const visible = usePageVisible(onShow);
90
+ const inbox = useInbox({
91
+ open,
92
+ visible,
93
+ pagePath: location.pathname,
94
+ language: browserLanguage(),
95
+ capabilities: pageCapabilities(bookHref, renderBooking),
96
+ });
97
+ wakeRef.current = inbox.wake;
98
+ // The settings last seen enabled: a panel left open when the business turns
99
+ // the button off keeps its words while it says the chat is not available.
100
+ const lastOn = useRef(null);
101
+ if (inbox.config?.enabled)
102
+ lastOn.current = inbox.config;
103
+ const config = inbox.config?.enabled ? inbox.config : open ? lastOn.current : null;
104
+ // A reply's email links here with #doany_inbox=<token>: read it, take only
105
+ // that key off the address (through the router, keeping its state), then
106
+ // open the chat with it.
107
+ const handledLink = useRef(null);
108
+ const { resume } = inbox;
109
+ useEffect(() => {
110
+ const token = linkTokenIn(location.hash);
111
+ if (!token || handledLink.current === token)
112
+ return;
113
+ handledLink.current = token;
114
+ navigate({ pathname: location.pathname, search: location.search, hash: hashWithoutLinkToken(location.hash) }, { replace: true, state: location.state });
115
+ setOpen(true);
116
+ void resume(token);
117
+ }, [location.hash, location.pathname, location.search, location.state, navigate, resume]);
118
+ const hidden = isHiddenPath(location.pathname, config?.hidden_paths);
119
+ useEffect(() => {
120
+ if (hidden)
121
+ setOpen(false);
122
+ }, [hidden]);
123
+ // The panel is gone: the keyboard goes back to the button that opened it,
124
+ // once the button is drawn again (a phone hides it under the full-screen panel).
125
+ const refocus = useRef(false);
126
+ useEffect(() => {
127
+ if (!open && refocus.current)
128
+ launcherRef.current?.focus();
129
+ refocus.current = false;
130
+ }, [open]);
131
+ if (!config || hidden || !inbox.ready)
132
+ return null;
133
+ const left = config.corner === "left";
134
+ const name = config.label || labels.askUs;
135
+ return (_jsxs("div", { className: cn("inbox-widget", className), "data-doany-inbox": "", children: [open ? (_jsx(Panel, { inbox: inbox, config: config, labels: labels, left: left, bookHref: bookHref, renderBooking: renderBooking, privacyHref: privacyHref, locale: locale, composer: composer, setComposer: setComposer, onClose: () => {
136
+ refocus.current = true;
137
+ setOpen(false);
138
+ }, navigate: (to) => navigate(to) })) : null, _jsxs("button", { ref: launcherRef, type: "button", "aria-expanded": open, "aria-haspopup": "dialog", "aria-label": open ? labels.close : name, title: open ? labels.close : name, onClick: () => {
139
+ refocus.current = open;
140
+ setOpen((was) => !was);
141
+ }, className: cn("fixed bottom-4 z-40 inline-flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-card transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", left ? "left-4" : "right-4",
142
+ // Under the full-screen panel on a phone, where each header has its own close.
143
+ open && "max-[480px]:hidden"), children: [open ? _jsx(ChevronDown, { "aria-hidden": "true", className: "h-6 w-6" }) : _jsx(MessageCircle, { "aria-hidden": "true", className: "h-6 w-6" }), inbox.unread && !open ? (_jsx("span", { className: "absolute right-0 top-0 h-3.5 w-3.5 rounded-full bg-destructive ring-2 ring-background", children: _jsx("span", { className: "sr-only", children: labels.unread }) })) : null] })] }));
144
+ }
145
+ const headerButton = "h-8 w-8 shrink-0";
146
+ function Panel({ inbox, config, labels, left, bookHref, renderBooking, privacyHref, locale, composer, setComposer, onClose, navigate, }) {
147
+ const titleId = useId();
148
+ const panelRef = useRef(null);
149
+ const scrollRef = useRef(null);
150
+ const business = config.business?.name ?? "";
151
+ const receptionist = config.receptionist?.name ?? "Annie";
152
+ const paths = config.paths ?? { ask: false, book: false, call: null, email: null, message: false };
153
+ const hosts = config.link_hosts ?? [];
154
+ const availability = config.availability;
155
+ const back = availability && !availability.open ? backAtParts(availability.back_at, locale) : null;
156
+ const backText = back ? labels.backAt(back.day, back.time) : null;
157
+ // While closed, "Back Mon 9:00" already says how long the wait is.
158
+ const repliesText = availability?.open && availability.replies_within != null ? labels.repliesWithin(availability.replies_within) : null;
159
+ // A handover line ends with the same wait: the next opening while closed, the usual reply time while open.
160
+ const wait = { business, repliesWithin: availability?.open ? (availability.replies_within ?? null) : null, backAt: backText };
161
+ const waitSaid = useMemo(() => saysTheWait(inbox.messages), [inbox.messages]);
162
+ const inThread = inbox.hasChat || inbox.pending !== null;
163
+ const offOrGone = inbox.off || !inbox.config?.enabled;
164
+ const canBook = paths.book && Boolean(bookHref);
165
+ // One way to write: to the receptionist while it answers, else to the team.
166
+ const canWrite = paths.ask || paths.message;
167
+ const [view, setView] = useState(inThread ? "chat" : "home");
168
+ // Who a first message goes to. Talk to a person, before the chat starts, sends it to the team.
169
+ const [intent, setIntent] = useState(inbox.draft?.intent ?? (paths.ask ? "ask" : "person"));
170
+ const [email, setEmail] = useState(inbox.draft?.contact?.email ?? "");
171
+ const [now, setNow] = useState(() => Date.now());
172
+ useEffect(() => {
173
+ const timer = setInterval(() => setNow(Date.now()), CLOCK_MS);
174
+ return () => clearInterval(timer);
175
+ }, []);
176
+ const answered = inbox.messages.some((message) => message.author === "receptionist");
177
+ // The receptionist is who the visitor talks to: it answers here, or already has.
178
+ const withAi = answered || (paths.ask && (inThread || intent === "ask"));
179
+ const hello = config.receptionist?.greeting || (paths.ask ? labels.greeting(receptionist, business) : labels.teamGreeting(business));
180
+ const who = withAi ? receptionist : business;
181
+ const signed = useMemo(() => bylines(inbox.messages, labels, receptionist, now, locale), [inbox.messages, labels, receptionist, now, locale]);
182
+ useEffect(() => {
183
+ panelRef.current?.focus();
184
+ }, []);
185
+ // A chat that arrives (the first message, an emailed link) opens the conversation.
186
+ useEffect(() => {
187
+ if (inThread)
188
+ setView("chat");
189
+ }, [inThread]);
190
+ // A refused first message comes back as a draft: show it where it was written.
191
+ const { draft } = inbox;
192
+ useEffect(() => {
193
+ if (!draft || inThread)
194
+ return;
195
+ setView("chat");
196
+ setIntent(draft.intent);
197
+ if (draft.contact?.email)
198
+ setEmail(draft.contact.email);
199
+ if (draft.text && !composer)
200
+ setComposer(draft.text);
201
+ // Only a new draft moves the view.
202
+ // eslint-disable-next-line react-hooks/exhaustive-deps
203
+ }, [draft, inThread]);
204
+ // A view that took the focused control away with it (Back, a tab, Start a
205
+ // new chat) leaves the keyboard on the panel, not on the page behind it.
206
+ // The conversation's box takes it first, in its own effect.
207
+ useEffect(() => {
208
+ if (document.activeElement === document.body)
209
+ panelRef.current?.focus();
210
+ }, [view, inThread]);
211
+ useEffect(() => {
212
+ const element = scrollRef.current;
213
+ if (element && view === "chat")
214
+ element.scrollTop = element.scrollHeight;
215
+ }, [inbox.messages.length, inbox.replyPending, inbox.pending, view]);
216
+ const goBook = () => {
217
+ if (!bookHref)
218
+ return;
219
+ onClose();
220
+ if (bookHref.startsWith("/"))
221
+ navigate(bookHref);
222
+ else
223
+ window.location.assign(bookHref);
224
+ };
225
+ // The privacy line: until the first message, or until the visitor hides it for this browser session.
226
+ const [privacyHidden, setPrivacyHidden] = useState(() => sessionMark(PRIVACY_DISMISSED_KEY));
227
+ const privacy = privacyHref && !inThread && !privacyHidden
228
+ ? {
229
+ href: privacyHref,
230
+ // A path on this site opens through the router. The panel stays open
231
+ // beside the page, except on a phone, where it covers the page.
232
+ open: () => {
233
+ if (window.matchMedia?.("(max-width: 480px)").matches)
234
+ onClose();
235
+ navigate(privacyHref);
236
+ },
237
+ dismiss: () => {
238
+ setSessionMark(PRIVACY_DISMISSED_KEY);
239
+ setPrivacyHidden(true);
240
+ },
241
+ }
242
+ : null;
243
+ // The questions to press instead of typing one: only before the first message, and only to the receptionist.
244
+ const suggestions = useMemo(() => (paths.ask && !inThread && intent === "ask" ? suggestedQuestionTexts(config.suggested_questions, labels) : []), [paths.ask, inThread, intent, config.suggested_questions, labels]);
245
+ const openChat = () => {
246
+ if (!inThread)
247
+ setIntent(paths.ask ? "ask" : "person");
248
+ setView("chat");
249
+ };
250
+ const startNew = () => {
251
+ inbox.newChat();
252
+ setComposer("");
253
+ setEmail("");
254
+ setIntent(paths.ask ? "ask" : "person");
255
+ setView("home");
256
+ };
257
+ // Escape closes the panel only when nothing inside it already used the key:
258
+ // the ⋯ menu closes itself on Escape and marks it handled.
259
+ const onKeyDown = (event) => {
260
+ if (event.key === "Escape" && !event.defaultPrevented)
261
+ onClose();
262
+ };
263
+ // The box gives way to one line: closed, or off (the business turned the
264
+ // button off, during a chat or since the settings were read).
265
+ const boxLine = inbox.closed ? labels.closed : offOrGone ? labels.off : null;
266
+ const problem = boxLine && (inbox.problem === "off" || inbox.problem === "closed") ? null : problemText(inbox.problem, labels);
267
+ const reachUs = (inbox.problem === "contact_limit" || inbox.problem === "chat_limit") && (paths.call || paths.email);
268
+ // The first message is still on its way: nothing else goes before it.
269
+ const boxDisabled = boxLine !== null || inbox.pending !== null;
270
+ const toPerson = async () => {
271
+ if (!inbox.hasChat) {
272
+ setIntent("person");
273
+ return;
274
+ }
275
+ if (boxDisabled || inbox.busy)
276
+ return;
277
+ if (await inbox.askForPerson(composer.trim() || undefined))
278
+ setComposer("");
279
+ };
280
+ // Talk to a person: in a chat the AI answers, or before it starts where the team also takes messages.
281
+ const offerPerson = paths.ask && (inbox.hasChat ? true : !inThread && paths.message && intent === "ask");
282
+ const chatEmail = inbox.me?.signed_in ? null : (inbox.email ?? (inbox.pending?.email || null));
283
+ const close = (onPrimary = false) => (_jsx(Button, { type: "button", variant: "ghost", size: "icon", className: cn(headerButton, onPrimary && "text-primary-foreground hover:bg-primary-foreground/15 hover:text-primary-foreground"), "aria-label": labels.close, onClick: onClose, children: _jsx(X, { "aria-hidden": "true" }) }));
284
+ const notices = inbox.linkExpired || backText || repliesText || availability?.away_notice ? (_jsxs("div", { className: "space-y-1", children: [inbox.linkExpired ? _jsx(EventLine, { children: labels.linkExpired }) : null, backText ? _jsx(EventLine, { children: backText }) : null, repliesText ? _jsx(EventLine, { children: repliesText }) : null, availability?.away_notice ? _jsx(EventLine, { children: availability.away_notice }) : null] })) : null;
285
+ const alerts = boxLine || problem || reachUs ? (_jsxs("div", { role: "alert", className: "space-y-2", children: [boxLine ? _jsx(EventLine, { children: boxLine }) : null, problem && !(inbox.problem === "chat_limit" && reachUs) ? _jsx(EventLine, { children: problem }) : null, reachUs ? _jsx(ReachUs, { call: paths.call, email: paths.email, labels: labels }) : null] })) : null;
286
+ const branding = config.branding ? (_jsx("div", { className: "px-4 pb-2 pt-1 text-center text-xs text-muted-foreground", children: _jsx("a", { href: "https://doany.ai", target: "_blank", rel: "noopener noreferrer", className: "hover:text-foreground", children: labels.poweredBy }) })) : null;
287
+ const tabs = (_jsxs("nav", { className: "grid grid-cols-2 border-t border-border", children: [_jsx(TabButton, { icon: Home, current: view === "home", onClick: () => setView("home"), children: labels.homeTab }), _jsx(TabButton, { icon: MessageSquareText, current: view === "messages", onClick: () => setView("messages"), badge: inbox.unread ? labels.unread : null, children: labels.messagesTab })] }));
288
+ const last = inbox.messages[inbox.messages.length - 1];
289
+ const lastOffer = [...inbox.messages].reverse().find((message) => message.kind === "offer" && message.author !== "client");
290
+ const lastCode = last?.kind === "event" && typeof last.payload?.code === "string" ? last.payload.code : undefined;
291
+ const preview = last
292
+ ? last.kind === "event"
293
+ ? eventText(lastCode, labels, waitSaid.has(last.id) ? wait : null) || labels.teamWillReply
294
+ : (last.text ?? "")
295
+ : (inbox.pending?.text ?? "");
296
+ const lastTeam = [...inbox.messages].reverse().find((message) => message.author === "team" && message.kind !== "event");
297
+ const rowName = lastTeam && !answered ? lastTeam.author_name || labels.team : who;
298
+ return (_jsxs(_Fragment, { children: [_jsx("div", { "aria-hidden": "true", "data-inbox-glow": "", className: cn("pointer-events-none fixed bottom-20 z-40 h-[min(40rem,calc(100dvh-6rem))] w-[min(25rem,calc(100vw-2rem))] scale-[1.04] rounded-[2rem] bg-gradient-to-br from-primary/60 via-primary/15 to-primary/50 blur-xl max-[480px]:hidden", left ? "left-4" : "right-4") }), _jsx("div", { ref: panelRef, role: "dialog", "aria-labelledby": titleId, tabIndex: -1, onKeyDown: onKeyDown, "data-view": view, className: cn("fixed bottom-20 z-40 flex h-[min(40rem,calc(100dvh-6rem))] w-[min(25rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-card focus:outline-none", left ? "left-4" : "right-4",
299
+ // A phone gets the whole screen.
300
+ "max-[480px]:inset-0 max-[480px]:h-auto max-[480px]:w-auto max-[480px]:rounded-none max-[480px]:border-0"), children: view === "home" ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto", children: [_jsxs("div", { className: "bg-primary px-5 pb-6 pt-4 text-primary-foreground", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("h2", { id: titleId, className: "min-w-0 flex-1 truncate font-heading text-base font-semibold", children: business }), close(true)] }), _jsx("p", { className: "mt-10 whitespace-pre-wrap font-heading text-2xl font-semibold leading-snug [overflow-wrap:anywhere]", children: hello })] }), _jsx("div", { "aria-hidden": "true", className: "h-12 bg-gradient-to-b from-primary to-card" }), _jsxs("div", { className: "-mt-10 space-y-2 px-4 pb-4", children: [canWrite ? (_jsx(HomeCard, { icon: MessageSquareText, title: paths.ask ? labels.ask : labels.message, sub: paths.ask ? labels.askSub(receptionist) : labels.messageSub, onClick: openChat })) : null, canBook ? _jsx(HomeCard, { icon: CalendarDays, title: labels.book, onClick: goBook }) : null, paths.call ? _jsx(HomeCard, { icon: Phone, title: labels.call, sub: paths.call, href: `tel:${paths.call}` }) : null, notices ? _jsx("div", { className: "pt-2", children: notices }) : null, !inThread && alerts ? _jsx("div", { className: "pt-2", children: alerts }) : null] })] }), tabs, branding] })) : view === "messages" ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [_jsx("h2", { id: titleId, className: "min-w-0 flex-1 truncate font-heading text-base font-semibold", children: labels.messagesTab }), close()] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto", children: inThread ? (_jsxs("button", { type: "button", className: "flex w-full items-center gap-3 border-b border-border px-4 py-3 text-left transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", onClick: () => setView("chat"), children: [_jsx(Avatar, { name: rowName }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate text-sm font-semibold", children: rowName }), _jsx("span", { className: "block truncate text-xs text-muted-foreground", children: preview })] }), _jsxs("span", { className: "flex shrink-0 flex-col items-end gap-1 text-xs text-muted-foreground", children: [last ? _jsx("span", { children: timeAgo(last.occurred_at, now, labels, locale) }) : null, inbox.unread ? (_jsx("span", { className: "h-2.5 w-2.5 rounded-full bg-primary", children: _jsx("span", { className: "sr-only", children: labels.unread }) })) : null] })] })) : (_jsx("p", { className: "px-4 py-10 text-center text-sm text-muted-foreground", children: labels.noMessages })) }), canWrite && !boxLine ? (_jsx("div", { className: "flex justify-center px-4 pb-3", children: _jsxs(Button, { type: "button", className: "rounded-full", onClick: openChat, children: [paths.ask ? labels.ask : labels.message, _jsx(Send, { "aria-hidden": "true" })] }) })) : null, tabs, branding] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border px-3 py-2.5", children: [_jsx(Button, { type: "button", variant: "ghost", size: "icon", className: headerButton, "aria-label": labels.back, onClick: () => setView("messages"), children: _jsx(ArrowLeft, { "aria-hidden": "true" }) }), _jsx(Avatar, { name: who }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h2", { id: titleId, className: "truncate font-heading text-sm font-semibold", children: who }), _jsx("p", { className: "truncate text-xs text-muted-foreground", children: withAi ? labels.teamCanHelp : labels.messageSub })] }), chatEmail || offerPerson || inThread ? (_jsx(MoreMenu, { label: labels.more, children: (done) => (_jsxs(_Fragment, { children: [chatEmail ? _jsx("p", { className: "px-3 py-1.5 text-xs text-muted-foreground [overflow-wrap:anywhere]", children: labels.chattingAs(chatEmail) }) : null, offerPerson ? (_jsx(MenuItem, { disabled: inbox.hasChat && (boxDisabled || inbox.busy), onClick: () => {
301
+ done();
302
+ void toPerson();
303
+ }, children: labels.askForPerson })) : null, inThread ? (_jsx(MenuItem, { onClick: () => {
304
+ done();
305
+ startNew();
306
+ }, children: labels.startNewChat })) : null] })) })) : null, close()] }), _jsxs("div", { ref: scrollRef, className: "min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4", children: [inbox.linkExpired ? _jsx(EventLine, { children: labels.linkExpired }) : null, withAi ? (_jsxs("div", { className: "space-y-1.5", children: [_jsx(TheirBubble, { children: aiNoticeText(labels, receptionist, business) }), _jsx(TheirBubble, { byline: `${receptionist} · ${labels.aiReceptionist}`, children: hello })] })) : (_jsx(TheirBubble, { byline: business || null, children: config.receptionist?.greeting || labels.teamGreeting(business) })), inThread ? (
307
+ // A log: a screen reader says each line as it arrives, without taking the keyboard.
308
+ _jsxs("div", { role: "log", "aria-live": "polite", "aria-relevant": "additions text", "aria-labelledby": titleId, className: "space-y-3", children: [inbox.messages.map((message) => (_jsx(MessageRow, { message: message, labels: labels, hosts: hosts, wait: waitSaid.has(message.id) ? wait : null, byline: signed.get(message.id) ?? null, locale: locale, renderBooking: renderBooking, onBooked: (result) => void inbox.reportBooking(result), newest: message.id === last?.id, latestOffer: message.id === lastOffer?.id }, message.id))), inbox.pending ? (_jsx(OwnBubble, { status: inbox.pending.state === "failed" ? (_jsxs(_Fragment, { children: [_jsx("span", { children: labels.notSent }), _jsx("button", { type: "button", className: "font-semibold text-foreground underline underline-offset-2", onClick: () => void inbox.retryPending(), disabled: inbox.busy, children: labels.tryAgain })] })) : (_jsx("span", { children: labels.sending })), children: inbox.pending.text })) : null, inbox.replyPending && inbox.hasChat ? _jsx(TypingDots, { label: labels.typing }) : null] })) : null, alerts] }), !boxLine && (inThread || canWrite) ? (_jsx(Composer, { inbox: inbox, labels: labels, intent: paths.ask ? intent : "person", askEmail: !inThread && (!inbox.signedIn || inbox.problem === "details_required"), email: email, setEmail: setEmail, text: composer, setText: setComposer, disabled: boxDisabled, suggestions: suggestions, privacy: privacy })) : null, branding] })) })] }));
309
+ }
310
+ function TabButton({ icon: Icon, current, onClick, badge, children }) {
311
+ return (_jsxs("button", { type: "button", "aria-current": current ? "page" : undefined, onClick: onClick, className: cn("relative flex flex-col items-center gap-0.5 px-2 py-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", current ? "text-primary" : "text-muted-foreground hover:text-foreground"), children: [_jsxs("span", { className: "relative", children: [_jsx(Icon, { "aria-hidden": "true", className: "h-5 w-5" }), badge ? (_jsx("span", { className: "absolute -right-1.5 -top-1 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-card", children: _jsx("span", { className: "sr-only", children: badge }) })) : null] }), children] }));
312
+ }
313
+ /** The conversation's ⋯ menu. Escape closes it and leaves the panel open. */
314
+ function MoreMenu({ label, children }) {
315
+ const [open, setOpen] = useState(false);
316
+ const wrapRef = useRef(null);
317
+ const buttonRef = useRef(null);
318
+ const done = () => setOpen(false);
319
+ useEffect(() => {
320
+ if (!open)
321
+ return;
322
+ wrapRef.current?.querySelector('[role="menuitem"]:not([disabled])')?.focus();
323
+ const outside = (event) => {
324
+ if (!wrapRef.current?.contains(event.target))
325
+ setOpen(false);
326
+ };
327
+ document.addEventListener("mousedown", outside);
328
+ return () => document.removeEventListener("mousedown", outside);
329
+ }, [open]);
330
+ const onKeyDown = (event) => {
331
+ if (!open)
332
+ return;
333
+ if (event.key === "Escape") {
334
+ event.preventDefault();
335
+ setOpen(false);
336
+ buttonRef.current?.focus();
337
+ return;
338
+ }
339
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp")
340
+ return;
341
+ const items = Array.from(wrapRef.current?.querySelectorAll('[role="menuitem"]:not([disabled])') ?? []);
342
+ if (!items.length)
343
+ return;
344
+ event.preventDefault();
345
+ const at = items.indexOf(document.activeElement);
346
+ items[(at + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length]?.focus();
347
+ };
348
+ return (_jsxs("div", { ref: wrapRef, className: "relative", onKeyDown: onKeyDown, children: [_jsx(Button, { ref: buttonRef, type: "button", variant: "ghost", size: "icon", className: headerButton, "aria-label": label, "aria-haspopup": "menu", "aria-expanded": open, onClick: () => setOpen((was) => !was), children: _jsx(MoreHorizontal, { "aria-hidden": "true" }) }), open ? (_jsx("div", { role: "menu", "aria-label": label, className: "absolute right-0 top-full z-10 mt-1 w-56 rounded-xl border border-border bg-popover py-1 text-popover-foreground shadow-card", children: children(done) })) : null] }));
349
+ }
350
+ function MenuItem({ children, onClick, disabled }) {
351
+ return (_jsx("button", { type: "button", role: "menuitem", disabled: disabled, onClick: onClick, className: "block w-full px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50", children: children }));
352
+ }
353
+ function ReachUs({ call, email, labels }) {
354
+ return (_jsxs("div", { className: "flex flex-wrap justify-center gap-2", children: [call ? (_jsx(Button, { asChild: true, variant: "outline", size: "sm", children: _jsxs("a", { href: `tel:${call}`, children: [_jsx(Phone, { "aria-hidden": "true" }), labels.call, " ", call] }) })) : null, email ? (_jsx(Button, { asChild: true, variant: "outline", size: "sm", children: _jsxs("a", { href: `mailto:${email}`, children: [_jsx(Mail, { "aria-hidden": "true" }), labels.emailUs] }) })) : null] }));
355
+ }
356
+ /**
357
+ * The conversation's one box. Until this browser has a chat it starts with an
358
+ * email row (a signed-in account skips it); then it is the message alone.
359
+ */
360
+ function Composer({ inbox, labels, intent, askEmail, email, setEmail, text: typed, setText, disabled, suggestions, privacy, }) {
361
+ const id = useId();
362
+ const emailRef = useRef(null);
363
+ const boxRef = useRef(null);
364
+ const [error, setError] = useState(null);
365
+ // The box takes the keyboard when the conversation opens and once it can be
366
+ // typed in again: the email first while it is asked for. Focus that is on
367
+ // anything else in the panel stays there.
368
+ useEffect(() => {
369
+ const field = (askEmail && !email ? emailRef.current : null) ?? boxRef.current;
370
+ const active = document.activeElement;
371
+ const lost = active === document.body || active === field?.closest('[role="dialog"]') || (active instanceof HTMLButtonElement && active.disabled);
372
+ if (!disabled && field && lost)
373
+ field.focus();
374
+ // The email row leaving (the chat started) is not a reason to move the keyboard.
375
+ // eslint-disable-next-line react-hooks/exhaustive-deps
376
+ }, [disabled]);
377
+ // `given` is a suggested question pressed with the email already there: sent as typed words are.
378
+ const submit = async (event, given) => {
379
+ event?.preventDefault();
380
+ if (disabled || inbox.busy)
381
+ return;
382
+ const text = given ?? typed;
383
+ if (inbox.hasChat) {
384
+ if (text.trim() && (await inbox.send(text)))
385
+ setText("");
386
+ return;
387
+ }
388
+ let contact;
389
+ if (askEmail) {
390
+ const typed = email.trim();
391
+ const found = !typed ? labels.emailRequired : typed.length > EMAIL_MAX || !EMAIL_PATTERN.test(typed) ? labels.emailFormat : null;
392
+ setError(found);
393
+ if (found) {
394
+ emailRef.current?.focus();
395
+ return;
396
+ }
397
+ contact = { email: typed };
398
+ }
399
+ if (!text.trim()) {
400
+ boxRef.current?.focus();
401
+ return;
402
+ }
403
+ if (await inbox.start({ text, intent, contact })) {
404
+ setText("");
405
+ // The email row is gone and Send was the last thing pressed: the next message is typed here.
406
+ boxRef.current?.focus();
407
+ }
408
+ };
409
+ // A suggested question goes into the box. With the email still to give, the
410
+ // keyboard goes to the email row and Send sends both; otherwise it is sent at once.
411
+ const ask = (question) => {
412
+ if (disabled || inbox.busy)
413
+ return;
414
+ setText(question);
415
+ if (askEmail && !email.trim()) {
416
+ emailRef.current?.focus();
417
+ return;
418
+ }
419
+ void submit(undefined, question);
420
+ };
421
+ const [beforeLink, afterLink] = privacyNoticeParts(labels);
422
+ return (_jsxs("form", { noValidate: true, className: "px-3 pb-2 pt-1", onSubmit: (event) => void submit(event), children: [suggestions.length ? (_jsx("div", { role: "group", "aria-label": labels.suggestedQuestions, className: "flex flex-wrap justify-end gap-1.5 pb-2", children: suggestions.map((question) => (_jsx("button", { type: "button", disabled: disabled || inbox.busy, onClick: () => ask(question), className: "max-w-full rounded-full border border-border bg-background px-3 py-1.5 text-left text-sm text-foreground transition-colors [overflow-wrap:anywhere] hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", children: question }, question))) })) : null, privacy ? (_jsxs("div", { className: "flex items-start justify-center gap-1 pb-1.5 pl-1 text-xs text-muted-foreground", children: [_jsxs("p", { className: "min-w-0 py-0.5 [overflow-wrap:anywhere]", children: [beforeLink, _jsx("a", { href: privacy.href, className: "underline underline-offset-2 hover:text-foreground", onClick: (event) => {
423
+ // Another site, or a press that asks for a new tab or window, is the browser's.
424
+ if (!privacy.href.startsWith("/") || privacy.href.startsWith("//") || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
425
+ return;
426
+ event.preventDefault();
427
+ privacy.open();
428
+ }, children: labels.privacyPolicy }), afterLink] }), _jsx("button", { type: "button", "aria-label": labels.dismiss, title: labels.dismiss, className: "shrink-0 rounded p-0.5 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", onClick: () => {
429
+ privacy.dismiss();
430
+ // The line took the pressed × with it: the keyboard goes back to the box.
431
+ ((askEmail && !email ? emailRef.current : null) ?? boxRef.current)?.focus();
432
+ }, children: _jsx(X, { "aria-hidden": "true", className: "h-3.5 w-3.5" }) })] })) : null, _jsxs("div", { className: "rounded-2xl border border-input bg-background focus-within:border-ring focus-within:ring-1 focus-within:ring-ring", children: [askEmail ? (_jsxs("div", { className: "border-b border-border px-3.5 py-2", children: [_jsx("label", { htmlFor: `${id}-email`, className: "sr-only", children: labels.emailLabel }), _jsx("input", { ref: emailRef, id: `${id}-email`, type: "email", inputMode: "email", autoComplete: "email", maxLength: EMAIL_MAX, placeholder: labels.emailPlaceholder, value: email, disabled: disabled, onChange: (event) => {
433
+ setEmail(event.target.value);
434
+ if (error)
435
+ setError(null);
436
+ }, "aria-invalid": error ? true : undefined, "aria-describedby": error ? `${id}-email-error` : undefined, "aria-required": "true", className: "w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50" }), _jsx(FieldError, { id: `${id}-email-error`, message: error ?? undefined })] })) : null, _jsxs("div", { className: "flex items-end gap-2 py-1.5 pl-3.5 pr-1.5", children: [_jsx("label", { htmlFor: `${id}-message`, className: "sr-only", children: labels.messageLabel }), _jsx("textarea", { ref: boxRef, id: `${id}-message`, rows: 1, value: typed, maxLength: MESSAGE_MAX, placeholder: labels.messagePlaceholder, onChange: (event) => setText(event.target.value), onKeyDown: (event) => {
437
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
438
+ event.preventDefault();
439
+ void submit();
440
+ }
441
+ }, disabled: disabled, className: "max-h-32 min-h-9 flex-1 resize-none bg-transparent py-1.5 text-sm leading-relaxed text-foreground outline-none [field-sizing:content] placeholder:text-muted-foreground disabled:opacity-50" }), _jsx(Button, { type: "submit", size: "icon", className: "h-9 w-9 shrink-0 rounded-full", "aria-label": inbox.busy ? labels.sending : labels.send, disabled: disabled || inbox.busy || !typed.trim(), children: inbox.busy ? _jsx(Loader2, { "aria-hidden": "true", className: "animate-spin" }) : _jsx(ArrowUp, { "aria-hidden": "true" }) })] })] })] }));
442
+ }
@@ -0,0 +1,67 @@
1
+ import { type ElementType, type ReactNode } from "react";
2
+ import { type InboxLabels, type InboxWait } from "../labels.js";
3
+ import type { InboxBooked, InboxBookingHooks, InboxBookingOffer, InboxMessage } from "../types.js";
4
+ /** Plain text, with links only to the site's own hosts. */
5
+ export declare function MessageText({ text, hosts }: {
6
+ text: string;
7
+ hosts: readonly string[];
8
+ }): import("react").JSX.Element;
9
+ /**
10
+ * A line from the business's side: the receptionist, a team member, or the
11
+ * greeting. `byline` goes under the last bubble of a group: who wrote it and when.
12
+ */
13
+ export declare function TheirBubble({ children, byline }: {
14
+ children: ReactNode;
15
+ byline?: string | null;
16
+ }): import("react").JSX.Element;
17
+ /** The round initial beside a name: the site's primary colour, tinted. */
18
+ export declare function Avatar({ name, className }: {
19
+ name: string;
20
+ className?: string;
21
+ }): import("react").JSX.Element;
22
+ export declare function OwnBubble({ children, status }: {
23
+ children: ReactNode;
24
+ status?: ReactNode;
25
+ }): import("react").JSX.Element;
26
+ /** A fixed line the platform wrote (by its code): centered, quiet. The safety line stands out. */
27
+ export declare function EventLine({ children, urgent }: {
28
+ children: ReactNode;
29
+ urgent?: boolean;
30
+ }): import("react").JSX.Element;
31
+ /** Draws the site's booking flow for an offer; `hooks.onBooked` goes to the flow's `onBooked`. */
32
+ export type RenderBooking = (offer: InboxBookingOffer, hooks: InboxBookingHooks) => ReactNode;
33
+ export declare function MessageRow({ message, labels, hosts, wait, byline, locale, renderBooking, onBooked, newest, latestOffer, }: {
34
+ message: InboxMessage;
35
+ labels: InboxLabels;
36
+ hosts: readonly string[];
37
+ /** Under the last bubble of a group from the business's side: "Annie · AI receptionist · 5 min. ago". */
38
+ byline?: string | null;
39
+ /** For a handover line: how long the team takes, or null when a later line says it (`saysTheWait`). */
40
+ wait: InboxWait | null;
41
+ /** For an offer's day. */
42
+ locale?: string;
43
+ renderBooking?: RenderBooking;
44
+ /** A booking made from an offer card. */
45
+ onBooked?: (result: InboxBooked) => void;
46
+ /** The thread's newest message: an offer here shows its booking flow already open. */
47
+ newest?: boolean;
48
+ /** The thread's latest offer: a flow that is open stays open until a newer offer arrives. */
49
+ latestOffer?: boolean;
50
+ }): import("react").JSX.Element | null;
51
+ /**
52
+ * Three dots while an answer is on its way. The words are real text for a
53
+ * screen reader: the message log around it announces them when they appear.
54
+ */
55
+ export declare function TypingDots({ label }: {
56
+ label: string;
57
+ }): import("react").JSX.Element;
58
+ /** One of Home's cards: a full-width row with an arrow, a button or a link. */
59
+ export declare function HomeCard({ icon: Icon, title, sub, href, onClick }: {
60
+ icon: ElementType;
61
+ title: string;
62
+ sub?: string | null;
63
+ href?: string;
64
+ onClick?: () => void;
65
+ }): import("react").JSX.Element;
66
+ /** Whether the page is in front: replies are marked seen only then. */
67
+ export declare function usePageVisible(onShow: () => void): boolean;