@cosmoops/connect-sdk 0.32.0 → 0.34.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/CHANGELOG.md +251 -0
- package/README.md +17 -5
- package/dist/cjs/client/components/access-gate.js +98 -2
- package/dist/cjs/client/components/visitor-chat.js +387 -0
- package/dist/cjs/client/components/visitor-stream.js +124 -0
- package/dist/cjs/client/index.js +16 -3
- package/dist/cjs/client/provider.js +29 -2
- package/dist/cjs/client/use-checkout.js +3 -1
- package/dist/cjs/helpers.js +46 -0
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/next/index.js +10 -3
- package/dist/cjs/server/client.js +8 -1
- package/dist/cjs/server/handler.js +142 -0
- package/dist/cjs/server/resources/ai.js +50 -0
- package/dist/cjs/server/resources/billing.js +5 -0
- package/dist/cjs/server/resources/enrichment.js +59 -0
- package/dist/cjs/server/resources/entitlements.js +47 -0
- package/dist/cjs/server/resources/visitor.js +227 -0
- package/dist/esm/client/components/access-gate.js +99 -3
- package/dist/esm/client/components/visitor-chat.js +384 -0
- package/dist/esm/client/components/visitor-stream.js +120 -0
- package/dist/esm/client/index.js +11 -1
- package/dist/esm/client/provider.js +28 -2
- package/dist/esm/client/use-checkout.js +3 -1
- package/dist/esm/helpers.js +43 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/next/index.js +10 -3
- package/dist/esm/server/client.js +8 -1
- package/dist/esm/server/handler.js +142 -0
- package/dist/esm/server/resources/ai.js +50 -0
- package/dist/esm/server/resources/billing.js +5 -0
- package/dist/esm/server/resources/enrichment.js +59 -0
- package/dist/esm/server/resources/entitlements.js +47 -0
- package/dist/esm/server/resources/visitor.js +222 -0
- package/dist/types/client/components/access-gate.d.ts +9 -0
- package/dist/types/client/components/visitor-chat.d.ts +120 -0
- package/dist/types/client/components/visitor-stream.d.ts +42 -0
- package/dist/types/client/index.d.ts +6 -1
- package/dist/types/client/provider.d.ts +176 -51
- package/dist/types/client/use-checkout.d.ts +18 -1
- package/dist/types/helpers.d.ts +36 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/server/client.d.ts +7 -1
- package/dist/types/server/handler.d.ts +0 -24
- package/dist/types/server/resources/ai.d.ts +48 -1
- package/dist/types/server/resources/billing.d.ts +4 -0
- package/dist/types/server/resources/enrichment.d.ts +57 -1
- package/dist/types/server/resources/entitlements.d.ts +40 -1
- package/dist/types/server/resources/visitor.d.ts +200 -0
- package/dist/types/types.d.ts +562 -0
- package/package.json +3 -2
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ConnectVisitorChat = ConnectVisitorChat;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const react_2 = require("@heroui/react");
|
|
8
|
+
const lucide_react_1 = require("lucide-react");
|
|
9
|
+
const cn_js_1 = require("../../cn.js");
|
|
10
|
+
const provider_js_1 = require("../provider.js");
|
|
11
|
+
const visitor_stream_js_1 = require("./visitor-stream.js");
|
|
12
|
+
/**
|
|
13
|
+
* The public chat bubble: a visitor with no account asks about your product,
|
|
14
|
+
* books a call, or gets started.
|
|
15
|
+
*
|
|
16
|
+
* ## It runs on pages that have no `ConnectProvider`
|
|
17
|
+
*
|
|
18
|
+
* Deliberately, and it is the main thing separating this component from every
|
|
19
|
+
* other one here. A marketing page usually has no session to hold and nothing
|
|
20
|
+
* on it that needs one, so requiring the provider would mean wrapping a
|
|
21
|
+
* landing page in an auth context to put a chat bubble on it. It reads the
|
|
22
|
+
* context through `useConnectOptional` and uses it only for `basePath` and the
|
|
23
|
+
* injected `fetch`; both have props, and both have defaults.
|
|
24
|
+
*
|
|
25
|
+
* ## Nothing happens until somebody opens it
|
|
26
|
+
*
|
|
27
|
+
* The conversation is minted on first open, never on mount. On a busy marketing
|
|
28
|
+
* site mounting would mean one Firestore write and one wasted session row per
|
|
29
|
+
* *page view*, for a bubble most visitors never touch - and the bubble itself
|
|
30
|
+
* needs no server state to render. The greeting arrives with that first call
|
|
31
|
+
* and is stored copy rather than generated, so opening costs one fast round
|
|
32
|
+
* trip and the visitor is reading real words immediately.
|
|
33
|
+
*
|
|
34
|
+
* ## It is built for a phone first
|
|
35
|
+
*
|
|
36
|
+
* Open, it is a full-height sheet on a narrow viewport and an anchored panel
|
|
37
|
+
* from `sm` up - a marketing chat that covers a desktop browser is harder to
|
|
38
|
+
* use, and one that does not cover a phone has nowhere to put a keyboard.
|
|
39
|
+
* `h-dvh` rather than `h-screen`, so the sheet tracks the visual viewport when
|
|
40
|
+
* the keyboard opens; `env(safe-area-inset-bottom)` on the composer, so the
|
|
41
|
+
* send button is not under a home indicator.
|
|
42
|
+
*
|
|
43
|
+
* The composer's input type is **the server's decision**, not a guess from what
|
|
44
|
+
* the assistant said - see `VisitorField`. That is what puts an `@` on the
|
|
45
|
+
* keyboard when an email is wanted and lets iOS offer a verification code
|
|
46
|
+
* straight from the notification.
|
|
47
|
+
*
|
|
48
|
+
* ## The times are drawn from the tool result, never the prose
|
|
49
|
+
*
|
|
50
|
+
* A turn that looked up availability carries the slots as structured data, and
|
|
51
|
+
* the buttons come from that. So a time the visitor taps is one the server
|
|
52
|
+
* offered, which is the property `/visitor/book` relies on and cannot check for
|
|
53
|
+
* itself.
|
|
54
|
+
*
|
|
55
|
+
* ## Replies render as text, not markdown
|
|
56
|
+
*
|
|
57
|
+
* `<ConnectAskAi>` lazy-loads a markdown renderer because it answers questions
|
|
58
|
+
* about a workspace's documents, where tables and code blocks carry meaning.
|
|
59
|
+
* This answers three-sentence questions about a product on a marketing page.
|
|
60
|
+
* Shipping 462 KB of renderer to a landing page for prose that is deliberately
|
|
61
|
+
* kept to a couple of lines is the wrong trade, so line breaks are preserved
|
|
62
|
+
* and nothing else is parsed.
|
|
63
|
+
*/
|
|
64
|
+
function ConnectVisitorChat({ workspaceId, assistantName, greeting = "Hi - ask me anything, or I can put you in touch with someone.", suggestions = [], bookingEventTypeSlug = null, basePath, page, title = "Chat with us", defaultOpen = false, showWhenSignedIn = false, fetch: fetchProp, }) {
|
|
65
|
+
const connect = (0, provider_js_1.useConnectOptional)();
|
|
66
|
+
const root = basePath ?? "/api/connect";
|
|
67
|
+
const [isOpen, setIsOpen] = (0, react_1.useState)(defaultOpen);
|
|
68
|
+
const [session, setSession] = (0, react_1.useState)(null);
|
|
69
|
+
const [messages, setMessages] = (0, react_1.useState)([]);
|
|
70
|
+
const [field, setField] = (0, react_1.useState)("message");
|
|
71
|
+
const [input, setInput] = (0, react_1.useState)("");
|
|
72
|
+
const [streaming, setStreaming] = (0, react_1.useState)("");
|
|
73
|
+
const [isBusy, setIsBusy] = (0, react_1.useState)(false);
|
|
74
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
75
|
+
const [offer, setOffer] = (0, react_1.useState)(null);
|
|
76
|
+
const [chosen, setChosen] = (0, react_1.useState)(null);
|
|
77
|
+
const [booked, setBooked] = (0, react_1.useState)(null);
|
|
78
|
+
const [turnsRemaining, setTurnsRemaining] = (0, react_1.useState)(1);
|
|
79
|
+
const abortRef = (0, react_1.useRef)(null);
|
|
80
|
+
const bottomRef = (0, react_1.useRef)(null);
|
|
81
|
+
const headingId = (0, react_1.useId)();
|
|
82
|
+
/*
|
|
83
|
+
* **Through the provider when there is one, and directly when there is not.**
|
|
84
|
+
*
|
|
85
|
+
* `mutateResource` and `streamResource` already prefix the provider's own
|
|
86
|
+
* `basePath` and go through its injected `fetch` - which is the seam Storybook
|
|
87
|
+
* and the docs site mount this against, so using them is what makes those two
|
|
88
|
+
* work with nothing passed. Without a provider there is no seam to inherit and
|
|
89
|
+
* the `fetch` prop stands in, defaulting to the global.
|
|
90
|
+
*
|
|
91
|
+
* Reaching into the context for `basePath` and `fetch` instead would have
|
|
92
|
+
* meant widening `ConnectContextValue` with two fields nothing else needs, to
|
|
93
|
+
* re-implement two methods it already has.
|
|
94
|
+
*/
|
|
95
|
+
const call = (0, react_1.useCallback)(async (path, body) => {
|
|
96
|
+
if (connect)
|
|
97
|
+
return connect.mutateResource(path, body);
|
|
98
|
+
const response = await (fetchProp ?? globalThis.fetch)(`${root}${path}`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
credentials: "same-origin",
|
|
101
|
+
headers: { accept: "application/json", "content-type": "application/json" },
|
|
102
|
+
body: JSON.stringify(body),
|
|
103
|
+
});
|
|
104
|
+
const payload = await response.json().catch(() => null);
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
throw new Error(payload?.error?.message ?? "Something went wrong. Please try again.");
|
|
107
|
+
}
|
|
108
|
+
return payload;
|
|
109
|
+
}, [connect, root, fetchProp]);
|
|
110
|
+
const openStream = (0, react_1.useCallback)(async (body, signal) => {
|
|
111
|
+
if (connect)
|
|
112
|
+
return connect.streamResource("/visitor/chat", body, { signal });
|
|
113
|
+
const response = await (fetchProp ?? globalThis.fetch)(`${root}/visitor/chat`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
credentials: "same-origin",
|
|
116
|
+
headers: { accept: "text/event-stream", "content-type": "application/json" },
|
|
117
|
+
body: JSON.stringify(body),
|
|
118
|
+
signal,
|
|
119
|
+
});
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
const envelope = await response.json().catch(() => null);
|
|
122
|
+
throw new Error(envelope?.error?.message ?? "Something went wrong. Please try again.");
|
|
123
|
+
}
|
|
124
|
+
return response;
|
|
125
|
+
}, [connect, root, fetchProp]);
|
|
126
|
+
/*
|
|
127
|
+
* Minted on first open. `storedSessionId` survives a reload, which is what
|
|
128
|
+
* lets somebody follow a link out of the chat and come back to it - on a
|
|
129
|
+
* phone, the most likely thing they will do.
|
|
130
|
+
*/
|
|
131
|
+
(0, react_1.useEffect)(() => {
|
|
132
|
+
if (!isOpen || session)
|
|
133
|
+
return;
|
|
134
|
+
let cancelled = false;
|
|
135
|
+
void (async () => {
|
|
136
|
+
try {
|
|
137
|
+
const opened = await call("/visitor/session", {
|
|
138
|
+
workspaceId,
|
|
139
|
+
page: page ?? (typeof location !== "undefined" ? location.pathname : null),
|
|
140
|
+
resume: storedSessionId(),
|
|
141
|
+
});
|
|
142
|
+
if (cancelled)
|
|
143
|
+
return;
|
|
144
|
+
rememberSessionId(opened.sessionId);
|
|
145
|
+
setSession(opened);
|
|
146
|
+
setMessages(opened.messages);
|
|
147
|
+
setField(opened.field);
|
|
148
|
+
setTurnsRemaining(opened.turnsRemaining);
|
|
149
|
+
// Already carries its written-out `label`, so a reopened bubble names
|
|
150
|
+
// the time without formatting an instant in the visitor's own zone.
|
|
151
|
+
if (opened.booking)
|
|
152
|
+
setBooked(opened.booking);
|
|
153
|
+
}
|
|
154
|
+
catch (cause) {
|
|
155
|
+
if (!cancelled) {
|
|
156
|
+
setError(cause instanceof Error ? cause.message : "The assistant is unavailable.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
return () => {
|
|
161
|
+
cancelled = true;
|
|
162
|
+
};
|
|
163
|
+
}, [isOpen, session, call, page, workspaceId]);
|
|
164
|
+
(0, react_1.useEffect)(() => {
|
|
165
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
166
|
+
}, [messages, streaming, offer, booked]);
|
|
167
|
+
// A conversation left mid-stream stops costing the application the moment the
|
|
168
|
+
// panel goes away.
|
|
169
|
+
(0, react_1.useEffect)(() => () => abortRef.current?.abort(), []);
|
|
170
|
+
const send = (0, react_1.useCallback)(async (text) => {
|
|
171
|
+
if (!session)
|
|
172
|
+
return;
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
abortRef.current = controller;
|
|
175
|
+
setError(null);
|
|
176
|
+
setIsBusy(true);
|
|
177
|
+
setOffer(null);
|
|
178
|
+
setMessages((current) => [
|
|
179
|
+
...current,
|
|
180
|
+
{ role: "visitor", text, at: new Date().toISOString() },
|
|
181
|
+
]);
|
|
182
|
+
let answer = "";
|
|
183
|
+
try {
|
|
184
|
+
const response = await openStream({
|
|
185
|
+
sessionId: session.sessionId,
|
|
186
|
+
workspaceId,
|
|
187
|
+
message: text,
|
|
188
|
+
assistantName: assistantName ?? "",
|
|
189
|
+
bookingEventTypeSlug,
|
|
190
|
+
}, controller.signal);
|
|
191
|
+
await (0, visitor_stream_js_1.readVisitorStream)(response, {
|
|
192
|
+
onDelta: (delta) => {
|
|
193
|
+
answer += delta;
|
|
194
|
+
setStreaming(answer);
|
|
195
|
+
},
|
|
196
|
+
onDone: (done) => {
|
|
197
|
+
setField(done.field);
|
|
198
|
+
setTurnsRemaining(done.turnsRemaining);
|
|
199
|
+
setOffer((0, visitor_stream_js_1.visitorSlotOfferFrom)(done.toolCalls));
|
|
200
|
+
},
|
|
201
|
+
onError: (message) => setError(message),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
catch (cause) {
|
|
205
|
+
if (!controller.signal.aborted) {
|
|
206
|
+
setError(cause instanceof Error ? cause.message : "Something went wrong.");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
if (abortRef.current === controller)
|
|
211
|
+
abortRef.current = null;
|
|
212
|
+
/*
|
|
213
|
+
* The streamed text is committed to the transcript here rather than as
|
|
214
|
+
* it arrives, so there is exactly one copy of the reply on screen at
|
|
215
|
+
* every moment - `streaming` is what is being written, `messages` is
|
|
216
|
+
* what has been said.
|
|
217
|
+
*/
|
|
218
|
+
if (answer.trim()) {
|
|
219
|
+
setMessages((current) => [
|
|
220
|
+
...current,
|
|
221
|
+
{ role: "assistant", text: answer.trim(), at: new Date().toISOString() },
|
|
222
|
+
]);
|
|
223
|
+
}
|
|
224
|
+
setStreaming("");
|
|
225
|
+
setIsBusy(false);
|
|
226
|
+
}
|
|
227
|
+
}, [session, openStream, workspaceId, assistantName, bookingEventTypeSlug]);
|
|
228
|
+
const bookChosenSlot = (0, react_1.useCallback)(async (details) => {
|
|
229
|
+
if (!session || !chosen)
|
|
230
|
+
return;
|
|
231
|
+
setIsBusy(true);
|
|
232
|
+
setError(null);
|
|
233
|
+
try {
|
|
234
|
+
const appointment = await call("/visitor/book", {
|
|
235
|
+
sessionId: session.sessionId,
|
|
236
|
+
workspaceId,
|
|
237
|
+
eventTypeSlug: bookingEventTypeSlug,
|
|
238
|
+
startAt: chosen.startAt,
|
|
239
|
+
name: details.name,
|
|
240
|
+
phone: details.phone,
|
|
241
|
+
email: details.email || null,
|
|
242
|
+
});
|
|
243
|
+
setBooked(appointment);
|
|
244
|
+
setChosen(null);
|
|
245
|
+
setOffer(null);
|
|
246
|
+
setMessages((current) => [
|
|
247
|
+
...current,
|
|
248
|
+
{
|
|
249
|
+
role: "assistant",
|
|
250
|
+
text: `You're booked for ${appointment.label}. A confirmation is on its way.`,
|
|
251
|
+
at: new Date().toISOString(),
|
|
252
|
+
},
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
catch (cause) {
|
|
256
|
+
setError(cause instanceof Error ? cause.message : "That time could not be booked.");
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
setIsBusy(false);
|
|
260
|
+
}
|
|
261
|
+
}, [session, chosen, call, workspaceId, bookingEventTypeSlug]);
|
|
262
|
+
/*
|
|
263
|
+
* A signed-in member has the application's own surfaces, and `<ConnectAskAi>`
|
|
264
|
+
* occupies this same corner - see `showWhenSignedIn`.
|
|
265
|
+
*/
|
|
266
|
+
if (!showWhenSignedIn && connect?.session?.user)
|
|
267
|
+
return null;
|
|
268
|
+
const exhausted = turnsRemaining <= 0;
|
|
269
|
+
const composerDisabled = isBusy || !session || exhausted;
|
|
270
|
+
const handleSubmit = (event) => {
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
const text = input.trim();
|
|
273
|
+
if (!text || composerDisabled)
|
|
274
|
+
return;
|
|
275
|
+
setInput("");
|
|
276
|
+
void send(text);
|
|
277
|
+
};
|
|
278
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [!isOpen && ((0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "primary", "aria-label": title, onPress: () => setIsOpen(true),
|
|
279
|
+
/*
|
|
280
|
+
* `size-14` on a phone and `size-12` from `sm` up: 56px is the floor
|
|
281
|
+
* for a one-handed thumb target on a surface where this is the only
|
|
282
|
+
* interactive element floating over the page.
|
|
283
|
+
*/
|
|
284
|
+
className: "fixed end-4 bottom-4 z-30 size-14 rounded-full shadow-lg sm:size-12", style: { marginBottom: "env(safe-area-inset-bottom)" }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.MessageCircle, { className: "size-6 sm:size-5" }) })), isOpen && ((0, jsx_runtime_1.jsxs)("div", { role: "dialog", "aria-modal": "true", "aria-labelledby": headingId,
|
|
285
|
+
/*
|
|
286
|
+
* A full-height sheet on a phone, an anchored panel from `sm` up.
|
|
287
|
+
* `h-dvh` rather than `h-screen`: `vh` is the *largest* viewport on
|
|
288
|
+
* iOS, so a `h-screen` sheet puts its composer under the keyboard.
|
|
289
|
+
*/
|
|
290
|
+
className: (0, cn_js_1.cn)("fixed inset-0 z-40 flex h-dvh flex-col bg-surface", "sm:inset-auto sm:end-6 sm:bottom-6 sm:h-[min(32rem,calc(100dvh-6rem))] sm:w-[22rem] sm:rounded-2xl sm:border sm:border-border sm:shadow-xl"), children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex shrink-0 items-center gap-2 border-b border-border px-4 py-3", children: [(0, jsx_runtime_1.jsx)("span", { id: headingId, className: "flex-1 truncate text-sm font-semibold", children: assistantName?.trim() || title }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "min-h-0 min-w-0 flex-1 space-y-3 overflow-y-auto p-4", children: [session && messages.length === 0 && ((0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: greeting })), messages.map((message, index) => ((0, jsx_runtime_1.jsx)(Bubble, { role: message.role, text: message.text }, `${message.at}-${index}`))), streaming && (0, jsx_runtime_1.jsx)(Bubble, { role: "assistant", text: streaming }), isBusy && !streaming && !chosen && ((0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", "aria-live": "polite", children: "Typing..." })), session && messages.length === 0 && suggestions.length > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap gap-2 pt-1", children: suggestions.map((suggestion) => ((0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "secondary", className: "h-auto min-h-11 whitespace-normal py-2 text-start sm:min-h-9", isDisabled: composerDisabled, onPress: () => void send(suggestion), children: suggestion }, suggestion))) })), offer && !chosen && !booked && ((0, jsx_runtime_1.jsx)(SlotPicker, { offer: offer, onChoose: setChosen, isDisabled: isBusy })), chosen && !booked && ((0, jsx_runtime_1.jsx)(AttendeeForm, { slotLabel: chosen.label, isBusy: isBusy, onCancel: () => setChosen(null), onSubmit: bookChosenSlot })), booked && ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-start gap-2 rounded-lg border border-border bg-accent/5 p-3 text-sm", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.CalendarCheck, { className: "mt-0.5 size-4 shrink-0 text-accent" }), (0, jsx_runtime_1.jsxs)("span", { children: ["Booked - ", booked.label] })] })), error && ((0, jsx_runtime_1.jsx)("p", { className: "text-xs text-content-danger", role: "alert", children: error })), exhausted && ((0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", children: "This conversation has reached its limit. Reload the page to start a new one." })), (0, jsx_runtime_1.jsx)("div", { ref: bottomRef })] }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex shrink-0 gap-2 border-t border-border p-3",
|
|
291
|
+
// So the send button is not under a home indicator.
|
|
292
|
+
style: { paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" }, children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", className: "min-w-0 flex-1", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "sr-only", children: FIELD_LABEL[field] }), (0, jsx_runtime_1.jsx)(react_2.Input, { ...FIELD_INPUT[field], placeholder: FIELD_LABEL[field], value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: composerDisabled })] }), (0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", className: "size-11 shrink-0 sm:size-10", isDisabled: composerDisabled || !input.trim(), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Send, { className: "size-4" }) })] })] }))] }));
|
|
293
|
+
}
|
|
294
|
+
function Bubble({ role, text }) {
|
|
295
|
+
const isVisitor = role === "visitor";
|
|
296
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: (0, cn_js_1.cn)("flex", isVisitor ? "justify-end" : "justify-start"), children: (0, jsx_runtime_1.jsx)("p", { className: (0, cn_js_1.cn)(
|
|
297
|
+
// `max-w-[85%]` rather than a fixed width: a one-word reply should not
|
|
298
|
+
// be a full-width slab, and a long one still has to wrap.
|
|
299
|
+
"max-w-[85%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap", isVisitor ? "bg-accent text-accent-foreground" : "bg-default"), children: text }) }));
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The times, as buttons.
|
|
303
|
+
*
|
|
304
|
+
* Every one of them came from the server in this turn - see
|
|
305
|
+
* `visitorSlotOfferFrom`. `min-h-11` is the 44px touch floor; a row of times is
|
|
306
|
+
* exactly the control people mis-tap when it is sized for a mouse.
|
|
307
|
+
*/
|
|
308
|
+
function SlotPicker({ offer, onChoose, isDisabled, }) {
|
|
309
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "space-y-2 rounded-lg border border-border p-3", children: [(0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: [offer.eventType, " \u00B7 ", offer.durationMinutes, " min \u00B7 times shown in ", offer.timezone] }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap gap-2", children: offer.slots.map((slot) => ((0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "secondary", className: "min-h-11 sm:min-h-9", isDisabled: isDisabled, onPress: () => onChoose(slot), children: slot.label }, slot.startAt))) })] }));
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Name, phone and optionally email, in real fields.
|
|
313
|
+
*
|
|
314
|
+
* The reason the assistant does not ask for these in prose: a phone number
|
|
315
|
+
* typed into a chat composer arrives as whatever the autocorrect did to it,
|
|
316
|
+
* where `type="tel" inputmode="tel" autocomplete="tel"` brings up a keypad and
|
|
317
|
+
* offers the number the browser already has.
|
|
318
|
+
*
|
|
319
|
+
* Phone is required because Connect's booking requires it - a host with no way
|
|
320
|
+
* to reach the attendee has an appointment with a stranger.
|
|
321
|
+
*/
|
|
322
|
+
function AttendeeForm({ slotLabel, isBusy, onCancel, onSubmit, }) {
|
|
323
|
+
const [name, setName] = (0, react_1.useState)("");
|
|
324
|
+
const [phone, setPhone] = (0, react_1.useState)("");
|
|
325
|
+
const [email, setEmail] = (0, react_1.useState)("");
|
|
326
|
+
const ready = name.trim().length > 0 && phone.trim().length >= 3;
|
|
327
|
+
return ((0, jsx_runtime_1.jsxs)("form", { className: "space-y-2 rounded-lg border border-border p-3", onSubmit: (event) => {
|
|
328
|
+
event.preventDefault();
|
|
329
|
+
if (ready && !isBusy)
|
|
330
|
+
onSubmit({ name: name.trim(), phone: phone.trim(), email: email.trim() });
|
|
331
|
+
}, children: [(0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: ["Booking ", slotLabel] }), (0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "text-xs", children: "Your name" }), (0, jsx_runtime_1.jsx)(react_2.Input, { autoComplete: "name", value: name, onChange: (event) => setName(event.currentTarget.value), disabled: isBusy })] }), (0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "text-xs", children: "Phone" }), (0, jsx_runtime_1.jsx)(react_2.Input, { type: "tel", inputMode: "tel", autoComplete: "tel", value: phone, onChange: (event) => setPhone(event.currentTarget.value), disabled: isBusy })] }), (0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "text-xs", children: "Email (optional)" }), (0, jsx_runtime_1.jsx)(react_2.Input, { type: "email", inputMode: "email", autoComplete: "email", value: email, onChange: (event) => setEmail(event.currentTarget.value), disabled: isBusy })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex gap-2 pt-1", children: [(0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", variant: "primary", className: "min-h-11 flex-1 sm:min-h-9", isDisabled: !ready || isBusy, children: "Confirm" }), (0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", variant: "tertiary", className: "min-h-11 sm:min-h-9", isDisabled: isBusy, onPress: onCancel, children: "Back" })] })] }));
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* The composer's label and input attributes, per field Connect asked for.
|
|
335
|
+
*
|
|
336
|
+
* Total `Record`s over `VisitorField`, so a field added to the union fails the
|
|
337
|
+
* build here rather than silently rendering a plain text box - which is the
|
|
338
|
+
* whole failure this mechanism exists to prevent, since a plain text box is
|
|
339
|
+
* exactly what the wrong keyboard looks like.
|
|
340
|
+
*/
|
|
341
|
+
const FIELD_LABEL = {
|
|
342
|
+
message: "Type a message...",
|
|
343
|
+
email: "Your email address",
|
|
344
|
+
code: "6-digit code",
|
|
345
|
+
name: "Your full name",
|
|
346
|
+
};
|
|
347
|
+
const FIELD_INPUT = {
|
|
348
|
+
message: {},
|
|
349
|
+
email: { type: "email", inputMode: "email", autoComplete: "email" },
|
|
350
|
+
/*
|
|
351
|
+
* `autocomplete="one-time-code"` is what lets iOS offer the code from the
|
|
352
|
+
* notification without the visitor leaving the chat, and `inputmode="numeric"`
|
|
353
|
+
* brings up a keypad rather than a keyboard.
|
|
354
|
+
*/
|
|
355
|
+
code: { inputMode: "numeric", autoComplete: "one-time-code", maxLength: 6 },
|
|
356
|
+
name: { autoComplete: "name" },
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* The conversation token, in `sessionStorage`.
|
|
360
|
+
*
|
|
361
|
+
* `sessionStorage` rather than a cookie or `localStorage`, and each half of that
|
|
362
|
+
* is deliberate: it survives a reload, which is what makes "follow a link out of
|
|
363
|
+
* the chat and come back" work; it does not travel to another tab or outlive the
|
|
364
|
+
* visit, which is right for a bearer token whose only job is to continue one
|
|
365
|
+
* conversation.
|
|
366
|
+
*
|
|
367
|
+
* Every access is wrapped: storage throws outright in some privacy modes, and a
|
|
368
|
+
* chat bubble that fails to open because a visitor has blocked site data is a
|
|
369
|
+
* worse outcome than one that forgets a conversation.
|
|
370
|
+
*/
|
|
371
|
+
const STORAGE_KEY = "connect.visitor.session";
|
|
372
|
+
function storedSessionId() {
|
|
373
|
+
try {
|
|
374
|
+
return sessionStorage.getItem(STORAGE_KEY);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function rememberSessionId(id) {
|
|
381
|
+
try {
|
|
382
|
+
sessionStorage.setItem(STORAGE_KEY, id);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
/* private mode, or site data blocked - the conversation still works, it just will not resume */
|
|
386
|
+
}
|
|
387
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readVisitorStream = readVisitorStream;
|
|
4
|
+
exports.visitorSlotOfferFrom = visitorSlotOfferFrom;
|
|
5
|
+
async function readVisitorStream(response, handlers) {
|
|
6
|
+
const body = response.body;
|
|
7
|
+
if (!body)
|
|
8
|
+
throw new Error("The assistant returned no reply.");
|
|
9
|
+
const reader = body.pipeThrough(new TextDecoderStream()).getReader();
|
|
10
|
+
let buffer = "";
|
|
11
|
+
for (;;) {
|
|
12
|
+
const { done, value } = await reader.read();
|
|
13
|
+
if (done)
|
|
14
|
+
break;
|
|
15
|
+
buffer += value;
|
|
16
|
+
/*
|
|
17
|
+
* Frames are separated by a blank line, so everything before the last one is
|
|
18
|
+
* complete and whatever follows waits for the next read. Splitting on single
|
|
19
|
+
* newlines would hand a half-received `data:` line to `JSON.parse`, which on
|
|
20
|
+
* a slow connection is most frames.
|
|
21
|
+
*/
|
|
22
|
+
const frames = buffer.split("\n\n");
|
|
23
|
+
buffer = frames.pop() ?? "";
|
|
24
|
+
for (const frame of frames)
|
|
25
|
+
dispatch(frame, handlers);
|
|
26
|
+
}
|
|
27
|
+
// A stream that ends mid-frame has nothing complete left in the buffer; one
|
|
28
|
+
// that ends on a frame boundary with no trailing blank line still has its
|
|
29
|
+
// last frame there, which is the shape a server that closes immediately
|
|
30
|
+
// after `done` produces.
|
|
31
|
+
if (buffer.trim())
|
|
32
|
+
dispatch(buffer, handlers);
|
|
33
|
+
}
|
|
34
|
+
function dispatch(frame, handlers) {
|
|
35
|
+
let event = "";
|
|
36
|
+
let data = "";
|
|
37
|
+
for (const line of frame.split("\n")) {
|
|
38
|
+
if (line.startsWith("event:"))
|
|
39
|
+
event = line.slice(6).trim();
|
|
40
|
+
else if (line.startsWith("data:"))
|
|
41
|
+
data += line.slice(5).trim();
|
|
42
|
+
}
|
|
43
|
+
if (!event || !data)
|
|
44
|
+
return;
|
|
45
|
+
let payload;
|
|
46
|
+
try {
|
|
47
|
+
payload = JSON.parse(data);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/*
|
|
51
|
+
* A frame that cannot be parsed is a frame that cannot be rendered. Dropping
|
|
52
|
+
* it keeps the rest of the reply flowing, which is strictly better than
|
|
53
|
+
* failing a whole turn - already paid for - over one malformed chunk.
|
|
54
|
+
*/
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const record = payload;
|
|
58
|
+
switch (event) {
|
|
59
|
+
case "delta":
|
|
60
|
+
if (typeof record.text === "string")
|
|
61
|
+
handlers.onDelta(record.text);
|
|
62
|
+
return;
|
|
63
|
+
case "done":
|
|
64
|
+
handlers.onDone({
|
|
65
|
+
toolCalls: Array.isArray(record.toolCalls) ? record.toolCalls : [],
|
|
66
|
+
field: record.field === "email" || record.field === "code" || record.field === "name"
|
|
67
|
+
? record.field
|
|
68
|
+
: "message",
|
|
69
|
+
turnsRemaining: typeof record.turnsRemaining === "number" ? record.turnsRemaining : 0,
|
|
70
|
+
// Defaults to `true` on an older server that does not send it: claiming
|
|
71
|
+
// a reply was ungrounded when it may not have been would put a false
|
|
72
|
+
// gap in somebody's knowledge-base report.
|
|
73
|
+
grounded: record.grounded !== false,
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
case "error":
|
|
77
|
+
handlers.onError(typeof record.message === "string"
|
|
78
|
+
? record.message
|
|
79
|
+
: "Sorry - something went wrong. Please try again.");
|
|
80
|
+
return;
|
|
81
|
+
default:
|
|
82
|
+
// An event this version does not know about. Ignored rather than treated
|
|
83
|
+
// as an error, so Connect can add one without breaking a pinned SDK.
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The times a turn looked up, lifted out of its tool calls.
|
|
89
|
+
*
|
|
90
|
+
* **This is what the buttons are drawn from, never the assistant's sentence.** A
|
|
91
|
+
* time the visitor taps is then one the server offered, which is what makes it
|
|
92
|
+
* impossible to book an appointment at a time nobody gave - the property
|
|
93
|
+
* `/visitor/book` depends on and cannot check for itself.
|
|
94
|
+
*
|
|
95
|
+
* `null` when the turn looked nothing up, and also when the lookup *refused* -
|
|
96
|
+
* a tool answering `ok: false` is the assistant being told there is nothing
|
|
97
|
+
* available, and it says so in prose. Rendering an empty slot list under that
|
|
98
|
+
* would be a second, emptier way of saying the same thing.
|
|
99
|
+
*/
|
|
100
|
+
function visitorSlotOfferFrom(toolCalls) {
|
|
101
|
+
// The last one wins: a turn that looked twice - a wider window after an empty
|
|
102
|
+
// first answer - should offer what it found, not what it did not.
|
|
103
|
+
for (let index = toolCalls.length - 1; index >= 0; index -= 1) {
|
|
104
|
+
const call = toolCalls[index];
|
|
105
|
+
if (!call || call.name !== "find_slots")
|
|
106
|
+
continue;
|
|
107
|
+
const result = call.result;
|
|
108
|
+
if (!result || result.ok !== true || !Array.isArray(result.slots))
|
|
109
|
+
continue;
|
|
110
|
+
const slots = result.slots
|
|
111
|
+
.map((slot) => slot)
|
|
112
|
+
.filter((slot) => typeof slot.startAt === "string" && typeof slot.label === "string")
|
|
113
|
+
.map((slot) => ({ startAt: slot.startAt, label: slot.label }));
|
|
114
|
+
if (slots.length === 0)
|
|
115
|
+
return null;
|
|
116
|
+
return {
|
|
117
|
+
eventType: typeof result.eventType === "string" ? result.eventType : "Call",
|
|
118
|
+
durationMinutes: typeof result.durationMinutes === "number" ? result.durationMinutes : 30,
|
|
119
|
+
timezone: typeof result.timezone === "string" ? result.timezone : "UTC",
|
|
120
|
+
slots,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
package/dist/cjs/client/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.
|
|
5
|
-
exports.
|
|
6
|
-
exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useBookableEventTypes = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useConnectWorkspaceMembers = exports.useConnectWorkspaces = exports.ConnectWorkspaceSwitcher = exports.resolvePlanGroup = exports.groupPlansByTier = exports.isFreePlan = exports.ACTION_LABEL = exports.discountLabel = exports.annualSaving = exports.checkoutSubscriptionId = exports.resolveAction = void 0;
|
|
4
|
+
exports.useConnectPush = exports.useActivePath = exports.ConnectSideNav = exports.ConnectSidebarNav = exports.RAIL_EXPANDED = exports.RAIL_COLLAPSED = exports.sidebarRowClass = exports.useConnectShell = exports.ConnectShellProvider = exports.ConnectSidebarRow = exports.ConnectSidebarFooter = exports.ConnectSidebarBody = exports.ConnectSidebarHeader = exports.ConnectSidebar = exports.AppShell = exports.ConnectAccountSessions = exports.ConnectSignOutButton = exports.ConnectHomeButton = exports.ConnectProfileButton = exports.ConnectInvoicesButton = exports.ConnectManageSubscriptionButton = exports.ConnectUserMenu = exports.ConnectProfileModal = exports.ConnectUserButton = exports.ConnectOneTap = exports.ConnectRegisterButton = exports.ConnectLoginButton = exports.ConnectLogo = exports.ConnectMark = exports.ConnectGrievanceNotice = exports.isConnectError = exports.ConnectError = exports.normalizePlanFeatures = exports.blockingBlockers = exports.CONNECT_APP_URL = exports.useConnectResource = exports.useConnectIntegrations = exports.useConnectSessions = exports.useConnectAddonUsage = exports.useConnectCompanyProfile = exports.useConnectApps = exports.useConnectPlans = exports.useConnectEntitlements = exports.useConnectPlatformAccess = exports.useConnectAudience = exports.useConnectWorkspaceType = exports.useConnectUser = exports.useConnectOptional = exports.useConnect = exports.ConnectProvider = void 0;
|
|
5
|
+
exports.ConnectFilterTabs = exports.ConnectCursorPagination = exports.ConnectConfirmDialog = exports.ConnectLinkButton = exports.ConnectPlansPage = exports.ConnectUsageMeter = exports.ConnectPlatformGate = exports.ConnectWorkspaceTypeGate = exports.useConnectFeature = exports.ConnectFeatureGate = exports.useConnectAccessGate = exports.ConnectAccessGate = exports.ConnectReadinessGate = exports.ConnectReadinessBanner = exports.resolveSubscriptionState = exports.ConnectSeatUsage = exports.ConnectTrialBanner = exports.ConnectSubscriptionCard = exports.ConnectNoSubscriptionCard = exports.ConnectSubscriptionGate = exports.ConnectSubscribeDialog = exports.ConnectPlans = exports.ConnectIntegrationsModal = exports.AI_TONE_DOT = exports.AI_TONE_CHIP = exports.AI_EMPTY_VALUE = exports.aiStatusTone = exports.isNumericAiFormat = exports.formatAiValue = exports.AiSources = exports.AiSteps = exports.AiDetails = exports.AiTimeline = exports.AiMetrics = exports.AiChart = exports.AiTable = exports.ConnectAskAi = exports.ConnectAppsGrid = exports.visitorSlotOfferFrom = exports.readVisitorStream = exports.ConnectVisitorChat = exports.isLinkableConnectScreen = exports.matchConnectScreen = exports.filterNavForAudience = exports.gateExemptPaths = exports.isNavItemActive = exports.resolveActivePath = exports.normalizePath = exports.flattenNav = exports.normalizeNav = void 0;
|
|
6
|
+
exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useBookableEventTypes = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useConnectWorkspaceMembers = exports.useConnectWorkspaces = exports.ConnectWorkspaceSwitcher = exports.resolvePlanGroup = exports.groupPlansByTier = exports.isFreePlan = exports.ACTION_LABEL = exports.discountLabel = exports.annualSaving = exports.checkoutSubscriptionId = exports.resolveAction = exports.monthlyOf = exports.formatPrice = exports.buildCheckoutUrl = exports.useConnectCheckout = void 0;
|
|
7
7
|
/**
|
|
8
8
|
* Browser half of `@cosmoops/connect-sdk` - React components and hooks.
|
|
9
9
|
*
|
|
@@ -19,6 +19,9 @@ exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.use
|
|
|
19
19
|
var provider_js_1 = require("./provider.js");
|
|
20
20
|
Object.defineProperty(exports, "ConnectProvider", { enumerable: true, get: function () { return provider_js_1.ConnectProvider; } });
|
|
21
21
|
Object.defineProperty(exports, "useConnect", { enumerable: true, get: function () { return provider_js_1.useConnect; } });
|
|
22
|
+
// For a component that must render on a page with no provider - see its own
|
|
23
|
+
// note. `<ConnectVisitorChat>` is the case it exists for.
|
|
24
|
+
Object.defineProperty(exports, "useConnectOptional", { enumerable: true, get: function () { return provider_js_1.useConnectOptional; } });
|
|
22
25
|
Object.defineProperty(exports, "useConnectUser", { enumerable: true, get: function () { return provider_js_1.useConnectUser; } });
|
|
23
26
|
Object.defineProperty(exports, "useConnectWorkspaceType", { enumerable: true, get: function () { return provider_js_1.useConnectWorkspaceType; } });
|
|
24
27
|
Object.defineProperty(exports, "useConnectAudience", { enumerable: true, get: function () { return provider_js_1.useConnectAudience; } });
|
|
@@ -126,6 +129,16 @@ Object.defineProperty(exports, "filterNavForAudience", { enumerable: true, get:
|
|
|
126
129
|
var ai_screens_js_1 = require("./components/ai-screens.js");
|
|
127
130
|
Object.defineProperty(exports, "matchConnectScreen", { enumerable: true, get: function () { return ai_screens_js_1.matchConnectScreen; } });
|
|
128
131
|
Object.defineProperty(exports, "isLinkableConnectScreen", { enumerable: true, get: function () { return ai_screens_js_1.isLinkableConnectScreen; } });
|
|
132
|
+
/*
|
|
133
|
+
* The public chat bubble. Unlike every other component here it renders for a
|
|
134
|
+
* visitor with **no account**, and works on a page with no `ConnectProvider`
|
|
135
|
+
* at all - which is most marketing pages.
|
|
136
|
+
*/
|
|
137
|
+
var visitor_chat_js_1 = require("./components/visitor-chat.js");
|
|
138
|
+
Object.defineProperty(exports, "ConnectVisitorChat", { enumerable: true, get: function () { return visitor_chat_js_1.ConnectVisitorChat; } });
|
|
139
|
+
var visitor_stream_js_1 = require("./components/visitor-stream.js");
|
|
140
|
+
Object.defineProperty(exports, "readVisitorStream", { enumerable: true, get: function () { return visitor_stream_js_1.readVisitorStream; } });
|
|
141
|
+
Object.defineProperty(exports, "visitorSlotOfferFrom", { enumerable: true, get: function () { return visitor_stream_js_1.visitorSlotOfferFrom; } });
|
|
129
142
|
var apps_grid_js_1 = require("./components/apps-grid.js");
|
|
130
143
|
Object.defineProperty(exports, "ConnectAppsGrid", { enumerable: true, get: function () { return apps_grid_js_1.ConnectAppsGrid; } });
|
|
131
144
|
// `AppShell askAi` mounts this; exported for an app that wants the panel
|
|
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
exports.CONNECT_APP_URL = void 0;
|
|
5
5
|
exports.ConnectProvider = ConnectProvider;
|
|
6
6
|
exports.useConnect = useConnect;
|
|
7
|
+
exports.useConnectOptional = useConnectOptional;
|
|
7
8
|
exports.useConnectUser = useConnectUser;
|
|
8
9
|
exports.useConnectWorkspaceType = useConnectWorkspaceType;
|
|
9
10
|
exports.useConnectAudience = useConnectAudience;
|
|
@@ -351,6 +352,29 @@ function useConnect() {
|
|
|
351
352
|
}
|
|
352
353
|
return value;
|
|
353
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* The context when there is one, `null` when there is not.
|
|
357
|
+
*
|
|
358
|
+
* **For a component that must work on a page with no provider**, which is a
|
|
359
|
+
* real case rather than defensive coding: `<ConnectVisitorChat>` goes on a
|
|
360
|
+
* public marketing page, and those pages frequently have no `ConnectProvider`
|
|
361
|
+
* anywhere near them - there is no session to hold and nothing on the page that
|
|
362
|
+
* needs one. `useConnect()` throwing is right for every component that reads a
|
|
363
|
+
* session; it is wrong for one whose whole purpose is the visitor who has none.
|
|
364
|
+
*
|
|
365
|
+
* What inheriting the context buys such a component, when it is inside one, is
|
|
366
|
+
* `basePath` and the injected `fetch` - so the same component renders against
|
|
367
|
+
* Storybook's mock bridge and the docs site's canned fetch without either of
|
|
368
|
+
* them having to pass anything.
|
|
369
|
+
*
|
|
370
|
+
* Do not reach for this to make an ordinary component tolerant of a missing
|
|
371
|
+
* provider. A session-reading component that silently renders a signed-out
|
|
372
|
+
* state because somebody forgot to wrap their app is a bug that looks like a
|
|
373
|
+
* product decision.
|
|
374
|
+
*/
|
|
375
|
+
function useConnectOptional() {
|
|
376
|
+
return (0, react_1.useContext)(ConnectContext);
|
|
377
|
+
}
|
|
354
378
|
/** The signed-in user, or `null`. Sugar over `useConnect().session`. */
|
|
355
379
|
function useConnectUser() {
|
|
356
380
|
return useConnect().session?.user ?? null;
|
|
@@ -479,7 +503,7 @@ function useConnectSessions() {
|
|
|
479
503
|
*/
|
|
480
504
|
function useConnectResource(path, options) {
|
|
481
505
|
const requiresSession = options?.requiresSession ?? true;
|
|
482
|
-
const { fetchResource, session, isLoading: sessionLoading } = useConnect();
|
|
506
|
+
const { fetchResource, invalidateResource, session, isLoading: sessionLoading } = useConnect();
|
|
483
507
|
// Stamped with the `path` it belongs to, so switching paths reports loading
|
|
484
508
|
// without a synchronous `setIsLoading(true)` - and, more usefully, so a
|
|
485
509
|
// resolved response for a previous path can never be read as this one's.
|
|
@@ -519,6 +543,9 @@ function useConnectResource(path, options) {
|
|
|
519
543
|
// should render its signed-out state, not skeletons forever.
|
|
520
544
|
isLoading: !signedOut && !loaded,
|
|
521
545
|
error: loaded?.error ?? null,
|
|
522
|
-
refetch: () =>
|
|
546
|
+
refetch: () => {
|
|
547
|
+
invalidateResource(path);
|
|
548
|
+
setVersion((v) => v + 1);
|
|
549
|
+
},
|
|
523
550
|
};
|
|
524
551
|
}
|
|
@@ -166,12 +166,14 @@ function buildCancelUrl({ appUrl, subscriptionId }) {
|
|
|
166
166
|
* prefix that has never served this page. Building the other one is what
|
|
167
167
|
* sent every first-time subscriber from a partner's pricing page to a 404.
|
|
168
168
|
*/
|
|
169
|
-
function buildCheckoutUrl({ appUrl, applicationId, planId, subscriptionId, coupon, }) {
|
|
169
|
+
function buildCheckoutUrl({ appUrl, applicationId, planId, subscriptionId, coupon, next, }) {
|
|
170
170
|
const path = subscriptionId
|
|
171
171
|
? `/billing/subscriptions/${encodeURIComponent(subscriptionId)}/change-plan/${encodeURIComponent(planId)}`
|
|
172
172
|
: `/apps/${encodeURIComponent(applicationId)}/subscribe/${encodeURIComponent(planId)}`;
|
|
173
173
|
const url = new URL(path, appUrl);
|
|
174
174
|
if (coupon?.trim())
|
|
175
175
|
url.searchParams.set("coupon", coupon.trim());
|
|
176
|
+
if (next?.trim())
|
|
177
|
+
url.searchParams.set("next", next.trim());
|
|
176
178
|
return url.toString();
|
|
177
179
|
}
|