@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,115 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "@doany-ai/app-core";
3
+ import { Button } from "@doany-ai/app-core/ui/button";
4
+ import { ChevronRight } from "lucide-react";
5
+ import { useEffect, useRef, useState } from "react";
6
+ import { offerDay, textParts } from "../format.js";
7
+ import { eventText } from "../labels.js";
8
+ /** Plain text, with links only to the site's own hosts. */
9
+ export function MessageText({ text, hosts }) {
10
+ return (_jsx(_Fragment, { children: textParts(text, hosts).map((part, index) => "href" in part ? (_jsx("a", { href: part.href, className: "underline underline-offset-2 [overflow-wrap:anywhere]", children: part.text }, index)) : (_jsx("span", { children: part.text }, index))) }));
11
+ }
12
+ const bubble = "max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed [overflow-wrap:anywhere]";
13
+ /**
14
+ * A line from the business's side: the receptionist, a team member, or the
15
+ * greeting. `byline` goes under the last bubble of a group: who wrote it and when.
16
+ */
17
+ export function TheirBubble({ children, byline }) {
18
+ return (_jsxs("div", { className: "flex flex-col items-start gap-1", children: [_jsx("div", { className: cn(bubble, "bg-muted text-foreground"), children: children }), byline ? _jsx("span", { className: "px-1 text-xs text-muted-foreground", children: byline }) : null] }));
19
+ }
20
+ /** The round initial beside a name: the site's primary colour, tinted. */
21
+ export function Avatar({ name, className }) {
22
+ const initial = Array.from(name.trim())[0]?.toUpperCase() ?? "";
23
+ return (_jsx("span", { "aria-hidden": "true", className: cn("inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-semibold text-primary", className), children: initial }));
24
+ }
25
+ export function OwnBubble({ children, status }) {
26
+ return (_jsxs("div", { className: "flex flex-col items-end gap-1", children: [_jsx("div", { className: cn(bubble, "bg-primary text-primary-foreground"), children: children }), status ? _jsx("div", { className: "flex items-center gap-2 px-1 text-xs text-muted-foreground", children: status }) : null] }));
27
+ }
28
+ /** A fixed line the platform wrote (by its code): centered, quiet. The safety line stands out. */
29
+ export function EventLine({ children, urgent }) {
30
+ return (_jsx("p", { className: cn("mx-auto max-w-[90%] text-center text-xs leading-relaxed", urgent ? "rounded-lg border border-destructive px-3 py-2 text-sm text-destructive" : "text-muted-foreground"), role: urgent ? "alert" : undefined, children: children }));
31
+ }
32
+ export function MessageRow({ message, labels, hosts, wait, byline = null, locale, renderBooking, onBooked = () => { }, newest = false, latestOffer = false, }) {
33
+ if (message.kind === "event") {
34
+ const code = typeof message.payload?.code === "string" ? message.payload.code : undefined;
35
+ const text = eventText(code, labels, wait);
36
+ return text ? _jsx(EventLine, { urgent: code === "safety", children: text }) : null;
37
+ }
38
+ if (message.author === "client") {
39
+ return message.text ? (_jsx(OwnBubble, { children: _jsx(MessageText, { text: message.text, hosts: hosts }) })) : null;
40
+ }
41
+ if (message.kind === "offer") {
42
+ return (_jsx(OfferCard, { message: message, labels: labels, hosts: hosts, byline: byline, locale: locale, renderBooking: renderBooking, onBooked: onBooked, newest: newest, latest: latestOffer }));
43
+ }
44
+ if (!message.text)
45
+ return null;
46
+ return (_jsx(TheirBubble, { byline: byline, children: _jsx(MessageText, { text: message.text, hosts: hosts }) }));
47
+ }
48
+ /** The offer a message carries: exactly what `renderBooking` is given. */
49
+ function offerOf(message) {
50
+ const payload = message.payload ?? {};
51
+ const { service_setting_id, catalog_item_variant_id, date, timezone } = payload;
52
+ return typeof service_setting_id === "string" && typeof catalog_item_variant_id === "string" && typeof date === "string" && typeof timezone === "string"
53
+ ? { service_setting_id, catalog_item_variant_id, date, timezone }
54
+ : null;
55
+ }
56
+ /**
57
+ * An offer from the receptionist: the day and that day's open times, written
58
+ * by the platform with their timezone, and the site's own booking flow on
59
+ * that day. The flow is already open under the thread's newest message; an
60
+ * older offer is a card with Book, which opens it. An open flow stays open
61
+ * when a line follows it (a reply, "You're booked") and gives way to a card
62
+ * when a newer offer arrives. The times are not held: the flow reads them again.
63
+ */
64
+ function OfferCard({ message, labels, hosts, byline, locale, renderBooking, onBooked, newest, latest, }) {
65
+ const [booking, setBooking] = useState(newest);
66
+ useEffect(() => {
67
+ if (newest)
68
+ setBooking(true);
69
+ else if (!latest)
70
+ setBooking(false);
71
+ }, [newest, latest]);
72
+ const flowRef = useRef(null);
73
+ const offer = offerOf(message);
74
+ const day = offer ? offerDay(offer.date, locale) : null;
75
+ const times = typeof message.payload?.times_text === "string" ? message.payload.times_text : null;
76
+ useEffect(() => {
77
+ if (booking)
78
+ flowRef.current?.scrollIntoView?.({ block: "nearest" });
79
+ }, [booking]);
80
+ return (_jsxs("div", { className: "space-y-2", children: [_jsxs(TheirBubble, { byline: byline, children: [message.text ? _jsx(MessageText, { text: message.text, hosts: hosts }) : null, day ? _jsx("span", { className: cn("block font-semibold", message.text && "mt-1"), children: day }) : null, times ? _jsx("span", { className: "block", children: times }) : null, offer && renderBooking && !booking ? (_jsx(Button, { type: "button", size: "sm", className: "mt-2", onClick: () => setBooking(true), children: labels.bookOffer })) : null] }), offer && renderBooking && booking ? (_jsx("div", { ref: flowRef, className: "min-w-0 rounded-lg border border-border bg-background text-foreground", children: renderBooking(offer, { onBooked }) })) : null] }));
81
+ }
82
+ /**
83
+ * Three dots while an answer is on its way. The words are real text for a
84
+ * screen reader: the message log around it announces them when they appear.
85
+ */
86
+ export function TypingDots({ label }) {
87
+ return (_jsxs("div", { className: "flex items-start", children: [_jsx("span", { className: "sr-only", children: label }), _jsx("div", { className: "flex items-center gap-1 rounded-2xl bg-muted px-3.5 py-3.5", children: [0, 150, 300].map((delay) => (_jsx("span", { "aria-hidden": "true", className: "h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground", style: { animationDelay: `${delay}ms` } }, delay))) })] }));
88
+ }
89
+ /** One of Home's cards: a full-width row with an arrow, a button or a link. */
90
+ export function HomeCard({ icon: Icon, title, sub, href, onClick }) {
91
+ const className = "flex w-full items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 text-left text-card-foreground shadow-soft transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
92
+ const body = (_jsxs(_Fragment, { children: [_jsx(Icon, { "aria-hidden": "true", className: "h-5 w-5 shrink-0 text-primary" }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block text-sm font-semibold", children: title }), sub ? _jsx("span", { className: "block truncate text-xs text-muted-foreground", children: sub }) : null] }), _jsx(ChevronRight, { "aria-hidden": "true", className: "h-4 w-4 shrink-0 text-muted-foreground" })] }));
93
+ return href ? (_jsx("a", { href: href, className: className, children: body })) : (_jsx("button", { type: "button", className: className, onClick: onClick, children: body }));
94
+ }
95
+ /** Whether the page is in front: replies are marked seen only then. */
96
+ export function usePageVisible(onShow) {
97
+ const [visible, setVisible] = useState(() => typeof document === "undefined" || document.visibilityState !== "hidden");
98
+ useEffect(() => {
99
+ if (typeof document === "undefined")
100
+ return;
101
+ const changed = () => {
102
+ const now = document.visibilityState !== "hidden";
103
+ setVisible(now);
104
+ if (now)
105
+ onShow();
106
+ };
107
+ window.addEventListener("focus", onShow);
108
+ document.addEventListener("visibilitychange", changed);
109
+ return () => {
110
+ window.removeEventListener("focus", onShow);
111
+ document.removeEventListener("visibilitychange", changed);
112
+ };
113
+ }, [onShow]);
114
+ return visible;
115
+ }
package/dist/core.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { CONFIG_TTL_MS, inboxKeys, useInbox, type UseInboxOptions, type UseInboxResult } from "./hooks.js";
2
+ export { BOOKING_REPORT_FOR_MS, BOOKING_REPORT_MS, IDLE_MS, InboxSession, MAX_BACKOFF_MS, POLL_CLOSED_MS, POLL_OPEN_MS, POLL_REPLY_MS, refusalOf, type InboxDraft, type InboxPendingMessage, type InboxProblem, type InboxSessionOptions, type InboxStartInput, type InboxState, } from "./session.js";
3
+ export { READ_KEY, defaultStorage, memoryStorage, type InboxStorage } from "./storage.js";
4
+ export { aiNoticeText, defaultInboxLabels, eventText, inboxEventLabelKeys, inboxLabels, inboxSuggestedQuestionLabelKeys, isHandoverCode, saysTheWait, suggestedQuestionTexts, waitText, type InboxLabels, type InboxWait, } from "./labels.js";
5
+ export { backAtParts, isHiddenPath, offerDay, relativeTime, textParts, type TextPart } from "./format.js";
6
+ export type { InboxAvailability, InboxBooked, InboxBookingHooks, InboxBookingOffer, InboxClient, InboxConfig, InboxContact, InboxMe, InboxMessage, InboxPaths, InboxPendingStart, InboxSendParams, InboxSendReceipt, InboxStartParams, InboxStartReceipt, InboxSuggestedQuestion, InboxThreadPage, } from "./types.js";
package/dist/core.js ADDED
@@ -0,0 +1,7 @@
1
+ // @doany-ai/inbox/core: the chat with no DOM and no router, for a site that
2
+ // draws its own chat UI. `@doany-ai/inbox` adds the default <InboxWidget />.
3
+ export { CONFIG_TTL_MS, inboxKeys, useInbox } from "./hooks.js";
4
+ export { BOOKING_REPORT_FOR_MS, BOOKING_REPORT_MS, IDLE_MS, InboxSession, MAX_BACKOFF_MS, POLL_CLOSED_MS, POLL_OPEN_MS, POLL_REPLY_MS, refusalOf, } from "./session.js";
5
+ export { READ_KEY, defaultStorage, memoryStorage } from "./storage.js";
6
+ export { aiNoticeText, defaultInboxLabels, eventText, inboxEventLabelKeys, inboxLabels, inboxSuggestedQuestionLabelKeys, isHandoverCode, saysTheWait, suggestedQuestionTexts, waitText, } from "./labels.js";
7
+ export { backAtParts, isHiddenPath, offerDay, relativeTime, textParts } from "./format.js";
@@ -0,0 +1,43 @@
1
+ export type TextPart = {
2
+ text: string;
3
+ } | {
4
+ text: string;
5
+ href: string;
6
+ };
7
+ /**
8
+ * A message's text in parts: plain text, and links where an address points at
9
+ * one of `hosts` (the site's live hostnames, compared without case). Anything
10
+ * else, another site included, stays text.
11
+ */
12
+ export declare function textParts(text: string, hosts: readonly string[]): TextPart[];
13
+ /** The weekday and time of `back_at` as the business's clock reads them (its own offset), in `locale`. */
14
+ export declare function backAtParts(iso: string | null | undefined, locale?: string): {
15
+ day: string;
16
+ time: string;
17
+ } | null;
18
+ /**
19
+ * How long ago a message was written, as people read it in `locale`
20
+ * ("5 min. ago", "2 hr. ago", "3 days ago"). Null under a minute, which the
21
+ * chat words itself ("Just now"); empty for a value that is not a time.
22
+ */
23
+ export declare function relativeTime(iso: string, now: number, locale?: string): string | null;
24
+ /**
25
+ * An offer's day (`YYYY-MM-DD`, a day on the business's calendar) as people
26
+ * read it in `locale`: `Sat, Nov 14`. Read from the date's own fields, never as
27
+ * an instant, so it is the same day wherever the visitor is. Null for a value
28
+ * that is not a day.
29
+ */
30
+ export declare function offerDay(date: string, locale?: string): string | null;
31
+ /** Whether `pathname` is under one of the business's hidden path prefixes (`/checkout` hides `/checkout/paid`, not `/checkouts`). */
32
+ export declare function isHiddenPath(pathname: string, prefixes: readonly string[] | undefined): boolean;
33
+ /** The key the reply email's button carries in the address's hash. */
34
+ export declare const LINK_PARAM = "doany_inbox";
35
+ /** The emailed link's token, read with `URLSearchParams` from the hash. */
36
+ export declare function linkTokenIn(hash: string): string | null;
37
+ /** The hash without that one key; everything else in it stays as written. */
38
+ export declare function hashWithoutLinkToken(hash: string): string;
39
+ /** The platform's email check, run before anything is sent (doany-app-backend `forms/validation.py` `_EMAIL`). */
40
+ export declare const EMAIL_PATTERN: RegExp;
41
+ export declare const EMAIL_MAX = 254;
42
+ export declare const NAME_MAX = 80;
43
+ export declare const MESSAGE_MAX = 1000;
package/dist/format.js ADDED
@@ -0,0 +1,136 @@
1
+ /** Where a chat's text may link: `http(s)` addresses only, and only to the site's own live hosts. */
2
+ const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
3
+ const TRAILING = /[.,;:!?)\]}'"]+$/;
4
+ /**
5
+ * A message's text in parts: plain text, and links where an address points at
6
+ * one of `hosts` (the site's live hostnames, compared without case). Anything
7
+ * else, another site included, stays text.
8
+ */
9
+ export function textParts(text, hosts) {
10
+ const allowed = new Set(hosts.map((host) => host.toLowerCase()));
11
+ const parts = [];
12
+ let last = 0;
13
+ for (const match of text.matchAll(URL_PATTERN)) {
14
+ const start = match.index ?? 0;
15
+ const raw = match[0].replace(TRAILING, "");
16
+ let href = null;
17
+ try {
18
+ const url = new URL(raw);
19
+ if (allowed.has(url.hostname.toLowerCase()) && !url.username && !url.password)
20
+ href = url.href;
21
+ }
22
+ catch {
23
+ href = null;
24
+ }
25
+ if (!href)
26
+ continue;
27
+ if (start > last)
28
+ parts.push({ text: text.slice(last, start) });
29
+ parts.push({ text: raw, href });
30
+ last = start + raw.length;
31
+ }
32
+ if (last < text.length)
33
+ parts.push({ text: text.slice(last) });
34
+ return parts;
35
+ }
36
+ /** The weekday and time of `back_at` as the business's clock reads them (its own offset), in `locale`. */
37
+ export function backAtParts(iso, locale) {
38
+ const match = iso ? /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso) : null;
39
+ if (!match)
40
+ return null;
41
+ const [, year, month, day, hour, minute] = match.map(Number);
42
+ const wall = Date.UTC(year, month - 1, day, hour, minute);
43
+ if (Number.isNaN(wall))
44
+ return null;
45
+ try {
46
+ return {
47
+ day: new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }).format(wall),
48
+ time: new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: "UTC" }).format(wall),
49
+ };
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /**
56
+ * How long ago a message was written, as people read it in `locale`
57
+ * ("5 min. ago", "2 hr. ago", "3 days ago"). Null under a minute, which the
58
+ * chat words itself ("Just now"); empty for a value that is not a time.
59
+ */
60
+ export function relativeTime(iso, now, locale) {
61
+ const at = Date.parse(iso);
62
+ if (Number.isNaN(at))
63
+ return "";
64
+ const seconds = Math.max(0, Math.round((now - at) / 1000));
65
+ if (seconds < 60)
66
+ return null;
67
+ const [value, unit] = seconds < 3600 ? [Math.floor(seconds / 60), "minute"] : seconds < 86400 ? [Math.floor(seconds / 3600), "hour"] : [Math.floor(seconds / 86400), "day"];
68
+ try {
69
+ return new Intl.RelativeTimeFormat(locale, { numeric: "always", style: "short" }).format(-value, unit);
70
+ }
71
+ catch {
72
+ return "";
73
+ }
74
+ }
75
+ /**
76
+ * An offer's day (`YYYY-MM-DD`, a day on the business's calendar) as people
77
+ * read it in `locale`: `Sat, Nov 14`. Read from the date's own fields, never as
78
+ * an instant, so it is the same day wherever the visitor is. Null for a value
79
+ * that is not a day.
80
+ */
81
+ export function offerDay(date, locale) {
82
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
83
+ if (!match)
84
+ return null;
85
+ const [, year, month, day] = match.map(Number);
86
+ const wall = Date.UTC(year, month - 1, day);
87
+ if (new Date(wall).getUTCDate() !== day)
88
+ return null;
89
+ try {
90
+ return new Intl.DateTimeFormat(locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }).format(wall);
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ /** Whether `pathname` is under one of the business's hidden path prefixes (`/checkout` hides `/checkout/paid`, not `/checkouts`). */
97
+ export function isHiddenPath(pathname, prefixes) {
98
+ return (prefixes ?? []).some((prefix) => {
99
+ const base = prefix.length > 1 ? prefix.replace(/\/+$/, "") : prefix;
100
+ if (!base)
101
+ return false;
102
+ if (base === "/")
103
+ return true;
104
+ return pathname === base || pathname.startsWith(`${base}/`);
105
+ });
106
+ }
107
+ /** The key the reply email's button carries in the address's hash. */
108
+ export const LINK_PARAM = "doany_inbox";
109
+ /** The emailed link's token, read with `URLSearchParams` from the hash. */
110
+ export function linkTokenIn(hash) {
111
+ if (!hash || hash.length < 2)
112
+ return null;
113
+ return new URLSearchParams(hash.slice(1)).get(LINK_PARAM) || null;
114
+ }
115
+ /** The hash without that one key; everything else in it stays as written. */
116
+ export function hashWithoutLinkToken(hash) {
117
+ const rest = hash
118
+ .slice(1)
119
+ .split("&")
120
+ .filter((part) => part && decodeURIComponentSafe(part.split("=")[0]) !== LINK_PARAM)
121
+ .join("&");
122
+ return rest ? `#${rest}` : "";
123
+ }
124
+ function decodeURIComponentSafe(value) {
125
+ try {
126
+ return decodeURIComponent(value.replace(/\+/g, " "));
127
+ }
128
+ catch {
129
+ return value;
130
+ }
131
+ }
132
+ /** The platform's email check, run before anything is sent (doany-app-backend `forms/validation.py` `_EMAIL`). */
133
+ export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
134
+ export const EMAIL_MAX = 254;
135
+ export const NAME_MAX = 80;
136
+ export const MESSAGE_MAX = 1000;
@@ -0,0 +1,69 @@
1
+ import { type InboxStartInput, type InboxState } from "./session.js";
2
+ import { type InboxStorage } from "./storage.js";
3
+ import type { InboxBooked, InboxConfig } from "./types.js";
4
+ /** The query keys this package reads under, for invalidating from elsewhere. */
5
+ export declare const inboxKeys: {
6
+ all: readonly ["doany", "inbox"];
7
+ config: () => readonly ["doany", "inbox", "config"];
8
+ };
9
+ /** The button's settings are read again at most this often alongside polling. */
10
+ export declare const CONFIG_TTL_MS = 60000;
11
+ export interface UseInboxOptions {
12
+ /** The panel is open: replies are read every 5 seconds and marked seen. Default false. */
13
+ open?: boolean;
14
+ /** The page is visible. Default true. Replies are marked seen only while it is. */
15
+ visible?: boolean;
16
+ /** Where the visitor is (`/pricing`), kept with the first message. */
17
+ pagePath?: string;
18
+ /** The visitor's language (`en-US`), kept with the first message. */
19
+ language?: string;
20
+ /** What this page can show beyond text: `["offer_booking"]` when it can open a booking flow from an offer card. */
21
+ capabilities?: string[];
22
+ /**
23
+ * Who is using the site: `guest`, or `user:<id>`. Left out, read from
24
+ * `<AuthProvider>` (a guest without one). The SDK keeps a chat per account,
25
+ * so a change reads the chat again.
26
+ */
27
+ actor?: string;
28
+ /** Where the package keeps which reply was last shown. Left out, the browser's localStorage. */
29
+ storage?: InboxStorage;
30
+ /** `false`: read nothing (the config included). Default true. */
31
+ enabled?: boolean;
32
+ }
33
+ export interface UseInboxResult extends InboxState {
34
+ /** The button's settings, once read. */
35
+ config: InboxConfig | undefined;
36
+ configLoading: boolean;
37
+ /** The site offers the chat now. */
38
+ available: boolean;
39
+ /** A site account is signed in: the first message may go without details. */
40
+ signedIn: boolean;
41
+ /** The first message of a chat. Resolves true once the platform has it. */
42
+ start(input: InboxStartInput): Promise<boolean>;
43
+ /** Sends a first message that was not confirmed again, unchanged. */
44
+ retryPending(): Promise<boolean>;
45
+ /** A later message. On false, keep the words: nothing was stored. */
46
+ send(text: string): Promise<boolean>;
47
+ /** Hands the chat to a person, with optional words. */
48
+ askForPerson(text?: string): Promise<boolean>;
49
+ /** Opens the token from a reply's emailed link (`#doany_inbox=`). */
50
+ resume(linkToken: string): Promise<boolean>;
51
+ /** A booking made from an offer in this chat (the booking flow's `onBooked`): the chat then shows it. */
52
+ reportBooking(result: InboxBooked): Promise<boolean>;
53
+ /** Forgets this browser's chat, with no call. */
54
+ newChat(): void;
55
+ /** The tab came back: polling restarts if it had stopped. */
56
+ wake(): void;
57
+ /** Clears the unread mark. */
58
+ markRead(): void;
59
+ /** Reads the button's settings again. */
60
+ refreshConfig(): void;
61
+ }
62
+ /**
63
+ * The chat on a site, with no DOM: the business's button settings, this
64
+ * browser's chat and its messages, and the calls that write.
65
+ * `<InboxWidget />` draws it; a site can draw its own.
66
+ *
67
+ * Mount it once per page: each instance keeps its own chat state and polls.
68
+ */
69
+ export declare function useInbox(options?: UseInboxOptions): UseInboxResult;
package/dist/hooks.js ADDED
@@ -0,0 +1,112 @@
1
+ import { useDoany } from "@doany-ai/app-core";
2
+ import { useAuth } from "@doany-ai/app-core/auth";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
5
+ import { isHiddenPath } from "./format.js";
6
+ import { InboxSession } from "./session.js";
7
+ import { defaultStorage } from "./storage.js";
8
+ /** The query keys this package reads under, for invalidating from elsewhere. */
9
+ export const inboxKeys = {
10
+ all: ["doany", "inbox"],
11
+ config: () => [...inboxKeys.all, "config"],
12
+ };
13
+ /** The button's settings are read again at most this often alongside polling. */
14
+ export const CONFIG_TTL_MS = 60000;
15
+ /** `useAuth()` where there is an `<AuthProvider>`; null where there is none (the page passes `actor`). */
16
+ function useOptionalAuth() {
17
+ try {
18
+ return useAuth();
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ /**
25
+ * The chat on a site, with no DOM: the business's button settings, this
26
+ * browser's chat and its messages, and the calls that write.
27
+ * `<InboxWidget />` draws it; a site can draw its own.
28
+ *
29
+ * Mount it once per page: each instance keeps its own chat state and polls.
30
+ */
31
+ export function useInbox(options = {}) {
32
+ const client = useDoany();
33
+ const auth = useOptionalAuth();
34
+ const enabled = options.enabled !== false;
35
+ const [session] = useState(() => new InboxSession(client, options.storage ?? defaultStorage()));
36
+ const state = useSyncExternalStore(session.subscribe, session.getState, session.getState);
37
+ const config = useQuery({
38
+ queryKey: inboxKeys.config(),
39
+ queryFn: () => client.inbox.config(),
40
+ enabled,
41
+ staleTime: CONFIG_TTL_MS,
42
+ });
43
+ // Who the chat belongs to is known once the sign-in is.
44
+ const actor = options.actor ?? (auth ? (auth.user ? `user:${auth.user.id}` : auth.authChecked || !auth.isLoadingAuth ? "guest" : null) : "guest");
45
+ useEffect(() => {
46
+ if (!enabled)
47
+ return;
48
+ session.attach();
49
+ return () => session.detach();
50
+ }, [session, enabled]);
51
+ useEffect(() => {
52
+ if (enabled && actor)
53
+ session.setActor(actor);
54
+ }, [session, enabled, actor]);
55
+ const open = options.open ?? false;
56
+ const visible = options.visible ?? true;
57
+ const capabilities = options.capabilities?.join(",") ?? "";
58
+ // The chat is read where it is offered (the business has the button on and
59
+ // this page is not one it hides), or while the panel is open.
60
+ const offered = config.data?.enabled === true && !(options.pagePath !== undefined && isHiddenPath(options.pagePath, config.data.hidden_paths));
61
+ const active = open || offered;
62
+ useEffect(() => {
63
+ session.setOptions({
64
+ active,
65
+ open,
66
+ visible,
67
+ pagePath: options.pagePath,
68
+ language: options.language,
69
+ capabilities: capabilities ? capabilities.split(",") : undefined,
70
+ });
71
+ }, [session, active, open, visible, options.pagePath, options.language, capabilities]);
72
+ // Read the settings again when the panel opens, and at most once a minute alongside the poll.
73
+ const refetch = config.refetch;
74
+ const updatedAt = useRef(0);
75
+ updatedAt.current = config.dataUpdatedAt;
76
+ useEffect(() => {
77
+ if (open && enabled)
78
+ void refetch();
79
+ }, [open, enabled, refetch]);
80
+ // A write said the button is off: the settings say what to show now.
81
+ const offNow = state.off || state.problem === "off";
82
+ useEffect(() => {
83
+ if (offNow && enabled)
84
+ void refetch();
85
+ }, [offNow, enabled, refetch]);
86
+ useEffect(() => {
87
+ session.onPoll = () => {
88
+ if (Date.now() - updatedAt.current >= CONFIG_TTL_MS)
89
+ void refetch();
90
+ };
91
+ return () => {
92
+ session.onPoll = null;
93
+ };
94
+ }, [session, refetch]);
95
+ return {
96
+ ...state,
97
+ config: config.data,
98
+ configLoading: config.isPending,
99
+ available: config.data?.enabled === true,
100
+ signedIn: Boolean(auth?.user) || (options.actor !== undefined && options.actor !== "guest"),
101
+ start: session.start,
102
+ retryPending: session.retryPending,
103
+ send: session.send,
104
+ askForPerson: session.askForPerson,
105
+ resume: session.resume,
106
+ reportBooking: session.reportBooking,
107
+ newChat: session.newChat,
108
+ wake: () => session.wake(),
109
+ markRead: session.markRead,
110
+ refreshConfig: () => void refetch(),
111
+ };
112
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./core.js";
2
+ export { InboxWidget, type InboxWidgetProps } from "./components/inbox-widget.js";
3
+ export type { RenderBooking } from "./components/parts.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // @doany-ai/inbox: the chat button on a Doany site.
2
+ //
3
+ // useInbox() holds the chat with no DOM, through the SDK client given to
4
+ // app-core's <DoanyProvider> (`client.inbox`). <InboxWidget /> is the site's
5
+ // button and panel: restyled through the theme variables, adjusted through
6
+ // props (its words through `labels`), or replaced while keeping the hook. The
7
+ // package ships no CSS: the site's Tailwind build scans it (see
8
+ // @doany-ai/app-core/tailwind-preset).
9
+ export * from "./core.js";
10
+ export { InboxWidget } from "./components/inbox-widget.js";