@cosmoops/connect-sdk 0.40.0 → 0.42.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/dist/cjs/client/components/access-gate.js +18 -40
  3. package/dist/cjs/client/components/apps-grid.js +11 -3
  4. package/dist/cjs/client/components/ask-ai-errors.js +109 -0
  5. package/dist/cjs/client/components/ask-ai-turns.js +12 -0
  6. package/dist/cjs/client/components/ask-ai.js +75 -8
  7. package/dist/cjs/client/components/call-button.js +398 -0
  8. package/dist/cjs/client/components/plans.js +8 -2
  9. package/dist/cjs/client/components/readiness.js +2 -1
  10. package/dist/cjs/client/components/subscription.js +4 -4
  11. package/dist/cjs/client/components/user-menu.js +16 -11
  12. package/dist/cjs/client/handoff.js +78 -0
  13. package/dist/cjs/client/index.js +15 -7
  14. package/dist/cjs/client/portal-links.js +1 -1
  15. package/dist/cjs/client/provider.js +75 -4
  16. package/dist/cjs/client/use-checkout.js +23 -16
  17. package/dist/cjs/server/client.js +5 -1
  18. package/dist/cjs/server/handler.js +77 -1
  19. package/dist/cjs/server/resources/calls.js +58 -0
  20. package/dist/cjs/server/resources/identity.js +12 -0
  21. package/dist/cjs/server/resources/whatsapp.js +58 -0
  22. package/dist/esm/client/components/access-gate.js +18 -40
  23. package/dist/esm/client/components/apps-grid.js +11 -3
  24. package/dist/esm/client/components/ask-ai-errors.js +102 -0
  25. package/dist/esm/client/components/ask-ai-turns.js +12 -0
  26. package/dist/esm/client/components/ask-ai.js +76 -9
  27. package/dist/esm/client/components/call-button.js +358 -0
  28. package/dist/esm/client/components/plans.js +8 -2
  29. package/dist/esm/client/components/readiness.js +2 -1
  30. package/dist/esm/client/components/subscription.js +4 -4
  31. package/dist/esm/client/components/user-menu.js +16 -11
  32. package/dist/esm/client/handoff.js +72 -0
  33. package/dist/esm/client/index.js +6 -4
  34. package/dist/esm/client/portal-links.js +1 -1
  35. package/dist/esm/client/provider.js +75 -4
  36. package/dist/esm/client/use-checkout.js +23 -16
  37. package/dist/esm/server/client.js +5 -1
  38. package/dist/esm/server/handler.js +77 -1
  39. package/dist/esm/server/resources/calls.js +58 -0
  40. package/dist/esm/server/resources/identity.js +12 -0
  41. package/dist/esm/server/resources/whatsapp.js +54 -0
  42. package/dist/types/client/components/ask-ai-errors.d.ts +41 -0
  43. package/dist/types/client/components/ask-ai-turns.d.ts +5 -0
  44. package/dist/types/client/components/call-button.d.ts +132 -0
  45. package/dist/types/client/handoff.d.ts +62 -0
  46. package/dist/types/client/index.d.ts +5 -1
  47. package/dist/types/client/portal-links.d.ts +1 -1
  48. package/dist/types/client/provider.d.ts +72 -5
  49. package/dist/types/client/use-checkout.d.ts +4 -1
  50. package/dist/types/server/client.d.ts +4 -1
  51. package/dist/types/server/resources/calls.d.ts +32 -1
  52. package/dist/types/server/resources/identity.d.ts +10 -0
  53. package/dist/types/server/resources/whatsapp.d.ts +44 -0
  54. package/dist/types/types.d.ts +152 -1
  55. package/package.json +4 -1
@@ -0,0 +1,358 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
4
+ import { Button } from "@heroui/react";
5
+ import { Mic, MicOff, Phone, PhoneOff, PhoneOutgoing } from "lucide-react";
6
+ import { cn } from "../../cn.js";
7
+ import { useConnect, useConnectResource } from "../provider.js";
8
+ import { useConnectPortalUrl } from "../portal-links.js";
9
+ const ConnectCallContext = createContext(null);
10
+ /** Start a call yourself. Must be inside `<ConnectCallProvider>`. */
11
+ export function useConnectCall() {
12
+ const value = useContext(ConnectCallContext);
13
+ if (!value) {
14
+ throw new Error("useConnectCall() needs a <ConnectCallProvider> above it.");
15
+ }
16
+ return value;
17
+ }
18
+ /** How long the SDK may take to sign in before the attempt is abandoned. */
19
+ const LOGIN_TIMEOUT_MS = 15_000;
20
+ /** How long the "Call ended" card stays before the bar goes away. */
21
+ const ENDED_CARD_MS = 4_000;
22
+ /** How long after a call to re-read the balance - the carrier's report settles it. */
23
+ const AVAILABILITY_SETTLE_MS = 3_000;
24
+ function messageFromCause(cause) {
25
+ if (typeof cause === "string" && cause.trim())
26
+ return cause.trim();
27
+ return "The call couldn't be connected.";
28
+ }
29
+ function errorMessage(cause) {
30
+ return cause instanceof Error && cause.message ? cause.message : "Something went wrong.";
31
+ }
32
+ /**
33
+ * Holds the call in progress and draws the in-call bar. See the module comment.
34
+ */
35
+ export function ConnectCallProvider({ children, onCallEnded, onError, className, }) {
36
+ const { mutateResource } = useConnect();
37
+ const [call, setCall] = useState(null);
38
+ const { data: availability, refetch: refreshAvailability } = useConnectResource("/calls/availability");
39
+ // Read inside `finish`, which is wired to SDK events once.
40
+ const refreshRef = useRef(refreshAvailability);
41
+ useEffect(() => {
42
+ refreshRef.current = refreshAvailability;
43
+ }, [refreshAvailability]);
44
+ const clientRef = useRef(null);
45
+ const placedRef = useRef(false);
46
+ const targetRef = useRef(null);
47
+ const callIdRef = useRef(null);
48
+ const endedRef = useRef(false);
49
+ const dismissTimer = useRef(null);
50
+ // Read inside SDK event handlers wired once, so they always see the latest.
51
+ const onCallEndedRef = useRef(onCallEnded);
52
+ useEffect(() => {
53
+ onCallEndedRef.current = onCallEnded;
54
+ }, [onCallEnded]);
55
+ const cancelOnServer = useCallback(() => {
56
+ const callId = callIdRef.current;
57
+ if (!callId)
58
+ return;
59
+ // Best effort: Connect closes an unclaimed call on its own within minutes.
60
+ void mutateResource("/calls/cancel", { callId }).catch(() => undefined);
61
+ }, [mutateResource]);
62
+ const finish = useCallback((notice) => {
63
+ if (endedRef.current)
64
+ return;
65
+ endedRef.current = true;
66
+ const callId = callIdRef.current;
67
+ const target = targetRef.current;
68
+ placedRef.current = false;
69
+ setCall((current) => current
70
+ ? {
71
+ ...current,
72
+ phase: "ended",
73
+ notice,
74
+ endedAfterSeconds: current.connectedAt
75
+ ? Math.round((Date.now() - current.connectedAt) / 1000)
76
+ : null,
77
+ }
78
+ : current);
79
+ if (dismissTimer.current)
80
+ clearTimeout(dismissTimer.current);
81
+ dismissTimer.current = setTimeout(() => setCall(null), ENDED_CARD_MS);
82
+ // The charge lands a moment after the line drops, so the minutes shown
83
+ // are re-read once it has had time to.
84
+ setTimeout(() => refreshRef.current(), AVAILABILITY_SETTLE_MS);
85
+ if (target) {
86
+ target.onEnded?.({ callId });
87
+ onCallEndedRef.current?.({ callId, phone: target.phone });
88
+ }
89
+ }, []);
90
+ const wire = useCallback((client) => {
91
+ const ringing = () => setCall((current) => (current ? { ...current, phase: "ringing" } : current));
92
+ client.on("onCalling", ringing);
93
+ client.on("onCallRemoteRinging", ringing);
94
+ client.on("onCallAnswered", () => setCall((current) => current ? { ...current, phase: "in-call", connectedAt: Date.now() } : current));
95
+ client.on("onCallTerminated", () => finish(null));
96
+ client.on("onCallFailed", (cause) => {
97
+ if (!placedRef.current)
98
+ cancelOnServer();
99
+ finish(messageFromCause(cause));
100
+ });
101
+ client.on("onMediaPermission", (event) => {
102
+ if (event?.status === "failure") {
103
+ cancelOnServer();
104
+ finish("Allow microphone access in your browser to make calls.");
105
+ }
106
+ });
107
+ }, [cancelOnServer, finish]);
108
+ const signIn = useCallback(async () => {
109
+ let client = clientRef.current;
110
+ if (!client) {
111
+ const { default: Plivo } = await import("plivo-browser-sdk");
112
+ client = new Plivo({
113
+ debug: "ERROR",
114
+ // Ask for the microphone when a call starts, not on page load.
115
+ permOnClick: true,
116
+ // Warn before a tab mid-call is closed.
117
+ closeProtection: true,
118
+ enableTracking: false,
119
+ }).client;
120
+ clientRef.current = client;
121
+ wire(client);
122
+ }
123
+ if (client.isLoggedIn)
124
+ return client;
125
+ const { token } = await mutateResource("/calls/browser-token", {});
126
+ const signedIn = client;
127
+ await new Promise((resolve, reject) => {
128
+ const timer = setTimeout(() => {
129
+ cleanup();
130
+ reject(new Error("Signing in to the phone service timed out."));
131
+ }, LOGIN_TIMEOUT_MS);
132
+ const onLogin = () => {
133
+ cleanup();
134
+ resolve();
135
+ };
136
+ const onLoginFailed = (cause) => {
137
+ cleanup();
138
+ reject(new Error(`Couldn't sign in to the phone service: ${messageFromCause(cause)}`));
139
+ };
140
+ function cleanup() {
141
+ clearTimeout(timer);
142
+ signedIn.off("onLogin", onLogin);
143
+ signedIn.off("onLoginFailed", onLoginFailed);
144
+ }
145
+ signedIn.on("onLogin", onLogin);
146
+ signedIn.on("onLoginFailed", onLoginFailed);
147
+ if (!signedIn.loginWithAccessToken(token)) {
148
+ cleanup();
149
+ reject(new Error("Couldn't sign in to the phone service."));
150
+ }
151
+ });
152
+ return signedIn;
153
+ }, [mutateResource, wire]);
154
+ const isBusy = call !== null && call.phase !== "ended";
155
+ const startCall = useCallback(async (target) => {
156
+ if (isBusy) {
157
+ onError?.("You're already on a call.");
158
+ return;
159
+ }
160
+ if (dismissTimer.current)
161
+ clearTimeout(dismissTimer.current);
162
+ targetRef.current = target;
163
+ callIdRef.current = null;
164
+ placedRef.current = false;
165
+ endedRef.current = false;
166
+ setCall({
167
+ phase: "connecting",
168
+ label: target.name?.trim() || target.phone,
169
+ number: target.phone,
170
+ callId: null,
171
+ connectedAt: null,
172
+ endedAfterSeconds: null,
173
+ muted: false,
174
+ notice: null,
175
+ });
176
+ try {
177
+ const client = await signIn();
178
+ const started = await mutateResource("/calls/start", {
179
+ phone: target.phone,
180
+ });
181
+ callIdRef.current = started.callId;
182
+ setCall((current) => current ? { ...current, callId: started.callId, number: started.toNumber } : current);
183
+ // Connect dials the number on the record, not this one - it is passed
184
+ // because the SDK requires a destination.
185
+ if (!client.call(started.toNumber.replace(/^\+/, ""), started.headers)) {
186
+ throw new Error("The phone service didn't accept the call.");
187
+ }
188
+ placedRef.current = true;
189
+ }
190
+ catch (cause) {
191
+ cancelOnServer();
192
+ // A refusal is usually news about the workspace - an empty balance, a
193
+ // number that has gone - so the buttons should learn it too.
194
+ refreshRef.current();
195
+ const message = errorMessage(cause);
196
+ if (onError) {
197
+ onError(message);
198
+ endedRef.current = true;
199
+ setCall(null);
200
+ target.onEnded?.({ callId: callIdRef.current });
201
+ onCallEndedRef.current?.({ callId: callIdRef.current, phone: target.phone });
202
+ }
203
+ else {
204
+ finish(message);
205
+ }
206
+ }
207
+ }, [cancelOnServer, finish, isBusy, mutateResource, onError, signIn]);
208
+ const hangUp = useCallback(() => {
209
+ const client = clientRef.current;
210
+ if (placedRef.current && client) {
211
+ client.hangup();
212
+ return; // `onCallTerminated` takes it from here.
213
+ }
214
+ cancelOnServer();
215
+ finish(null);
216
+ }, [cancelOnServer, finish]);
217
+ const toggleMute = useCallback(() => {
218
+ const client = clientRef.current;
219
+ if (!client)
220
+ return;
221
+ setCall((current) => {
222
+ if (!current)
223
+ return current;
224
+ if (current.muted)
225
+ client.unmute();
226
+ else
227
+ client.mute();
228
+ return { ...current, muted: !current.muted };
229
+ });
230
+ }, []);
231
+ // Sign out of the carrier when the provider unmounts.
232
+ useEffect(() => () => {
233
+ clientRef.current?.logout();
234
+ if (dismissTimer.current)
235
+ clearTimeout(dismissTimer.current);
236
+ }, []);
237
+ const value = useMemo(() => ({ isBusy, startCall, availability, refreshAvailability }), [isBusy, startCall, availability, refreshAvailability]);
238
+ return (_jsxs(ConnectCallContext.Provider, { value: value, children: [children, call && (_jsx(ConnectActiveCallBar, { call: call, onHangUp: hangUp, onToggleMute: toggleMute, className: className }))] }));
239
+ }
240
+ /**
241
+ * Rings `phone` from the workspace's number. Needs `<ConnectCallProvider>`
242
+ * above it, and a member with "Make calls" - render it only for them, since a
243
+ * button the server refuses for everyone else helps nobody.
244
+ */
245
+ export function ConnectCallButton({ phone, name, onEnded, variant = "default", children, isDisabled, className, }) {
246
+ const { isBusy, startCall, availability } = useConnectCall();
247
+ const press = () => void startCall({ phone, name, onEnded });
248
+ // Not offered at all to someone who may not call - a button that always
249
+ // refuses helps nobody.
250
+ if (availability?.status === "no_permission")
251
+ return null;
252
+ // Anything else short of `available` says why, where the button would be,
253
+ // with the next step for whoever can take it.
254
+ if (availability && availability.status !== "available") {
255
+ return variant === "compact" ? (_jsx(Button, { isIconOnly: true, size: "sm", variant: "ghost", "aria-label": unavailableReason(availability).text, isDisabled: true, className: className, children: _jsx(Phone, { className: "size-3.5" }) })) : (_jsx(ConnectCallUnavailable, { availability: availability, className: className }));
256
+ }
257
+ if (variant === "compact") {
258
+ return (_jsx(Button, { isIconOnly: true, size: "sm", variant: "ghost", "aria-label": `Call ${name?.trim() || phone}`, isDisabled: isBusy || isDisabled, onPress: press, className: className, children: _jsx(Phone, { className: "size-3.5" }) }));
259
+ }
260
+ return (_jsxs(Button, { size: "sm", variant: "primary", isDisabled: isBusy || isDisabled || availability === null, onPress: press, className: cn("shrink-0", className), children: [_jsx(Phone, { className: "size-4" }), children ?? "Call"] }));
261
+ }
262
+ /**
263
+ * What to say when a member cannot call, and the one link that fixes it -
264
+ * offered only to someone with the permission to follow it.
265
+ */
266
+ function unavailableReason(availability) {
267
+ switch (availability.status) {
268
+ case "no_number":
269
+ return availability.canManageNumber && availability.offer
270
+ ? {
271
+ text: "Calling needs a business number.",
272
+ action: {
273
+ label: `Add ${availability.offer.addonName}`,
274
+ path: `/billing/subscriptions/${availability.offer.subscriptionId}`,
275
+ },
276
+ }
277
+ : { text: "Calling needs a business number - ask a workspace admin.", action: null };
278
+ case "number_pending":
279
+ return availability.canManageNumber
280
+ ? {
281
+ text: "Your business number is still being set up.",
282
+ action: { label: "Check calling", path: "/calling" },
283
+ }
284
+ : { text: "Your business number is still being set up.", action: null };
285
+ case "no_balance":
286
+ return availability.canTopUp
287
+ ? { text: "Out of calling minutes.", action: { label: "Top up", path: "/calling" } }
288
+ : { text: "Out of calling minutes - ask a workspace admin to top up.", action: null };
289
+ default:
290
+ return { text: "Calling isn't available.", action: null };
291
+ }
292
+ }
293
+ /** The inline "why not" a Call button becomes when the member cannot call. */
294
+ function ConnectCallUnavailable({ availability, className, }) {
295
+ const { text, action } = unavailableReason(availability);
296
+ const href = useConnectPortalUrl(action?.path ?? "/calling");
297
+ return (_jsxs("p", { className: cn("text-xs text-muted", className), children: [text, " ", action && (_jsx("a", { href: href, className: "font-medium text-accent hover:underline", children: action.label }))] }));
298
+ }
299
+ function formatRupees(paise) {
300
+ return `₹${(paise / 100).toLocaleString("en-IN", {
301
+ minimumFractionDigits: 2,
302
+ maximumFractionDigits: 2,
303
+ })}`;
304
+ }
305
+ /**
306
+ * The workspace's calling at a glance - its business number, the minutes
307
+ * left, and the next step - for a settings page. Reads the same availability
308
+ * the Call buttons do, so the two never disagree. Buying minutes and adding a
309
+ * number happen in Connect, and come back here when done.
310
+ */
311
+ export function ConnectCallingStatus({ className }) {
312
+ const { availability } = useConnectCall();
313
+ const callingHref = useConnectPortalUrl("/calling");
314
+ const offerHref = useConnectPortalUrl(availability?.offer ? `/billing/subscriptions/${availability.offer.subscriptionId}` : "/calling");
315
+ if (!availability) {
316
+ return _jsx("div", { className: cn("h-24 animate-pulse rounded-lg bg-surface-secondary", className) });
317
+ }
318
+ const rate = formatRupees(availability.ratePaisePerMinute);
319
+ return (_jsxs("div", { className: cn("space-y-3 rounded-lg border border-separator bg-surface p-4", className), children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-xs text-muted", children: "Business number" }), _jsx("p", { className: "text-sm font-medium tabular-nums", children: availability.number ?? (availability.status === "number_pending" ? "Being set up…" : "None") })] }), availability.number && (_jsxs("div", { className: "text-right", children: [_jsx("p", { className: "text-xs text-muted", children: "Calling minutes" }), _jsxs("p", { className: cn("text-sm font-medium tabular-nums", availability.lowBalance && "text-danger"), children: [formatRupees(availability.available), " \u00B7 about ", availability.availableMinutes, " min"] })] }))] }), availability.status === "no_permission" ? (_jsx("p", { className: "text-xs text-muted", children: "You don't have permission to make calls here." })) : !availability.number ? (_jsxs("p", { className: "text-xs text-muted", children: ["Call customers from their record, from your own business number. Calls are not recorded, and minutes are prepaid at ", rate, " + GST a minute.", " ", availability.status === "number_pending"
320
+ ? availability.canManageNumber && (_jsx("a", { href: callingHref, className: "font-medium text-accent hover:underline", children: "Check on your number" }))
321
+ : availability.canManageNumber && availability.offer
322
+ ? (_jsxs("a", { href: offerHref, className: "font-medium text-accent hover:underline", children: ["Add ", availability.offer.addonName, " (", formatRupees(availability.offer.price), " + GST)"] }))
323
+ : "Ask a workspace admin to add a business number."] })) : (_jsxs("p", { className: "text-xs text-muted", children: [availability.lowBalance
324
+ ? "Calling minutes are running low. "
325
+ : `Calls cost ${rate} + GST a minute, from prepaid minutes. `, availability.canTopUp ? (_jsx("a", { href: callingHref, className: "font-medium text-accent hover:underline", children: "Top up minutes" })) : ("A workspace admin can top up.")] }))] }));
326
+ }
327
+ const PHASE_LABEL = {
328
+ connecting: "Connecting…",
329
+ ringing: "Calling…",
330
+ ended: "Call ended",
331
+ };
332
+ function formatSeconds(seconds) {
333
+ const whole = Math.max(0, Math.round(seconds));
334
+ const minutes = Math.floor(whole / 60);
335
+ return `${minutes}:${String(whole % 60).padStart(2, "0")}`;
336
+ }
337
+ /** The floating in-call card. Exported for Storybook only - the provider draws it. */
338
+ export function ConnectActiveCallBar({ call, onHangUp, onToggleMute, className, }) {
339
+ const elapsed = useElapsedSeconds(call.phase === "in-call" ? call.connectedAt : null);
340
+ const live = call.phase !== "ended";
341
+ const status = call.phase === "in-call"
342
+ ? formatSeconds(elapsed)
343
+ : call.phase === "ended" && call.endedAfterSeconds !== null
344
+ ? `Call ended · ${formatSeconds(call.endedAfterSeconds)}`
345
+ : PHASE_LABEL[call.phase];
346
+ return (_jsx("div", { role: "status", "aria-live": "polite", className: cn("fixed inset-x-0 bottom-4 z-50 flex justify-center px-4", className), children: _jsxs("div", { className: "flex w-full max-w-sm items-center gap-3 rounded-2xl border border-separator bg-surface p-3 shadow-lg", children: [_jsx("span", { className: "flex size-9 shrink-0 items-center justify-center rounded-full bg-accent-soft text-accent", children: _jsx(PhoneOutgoing, { "aria-hidden": true, className: "size-4" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium", children: call.label }), _jsxs("p", { className: "truncate text-xs text-muted tabular-nums", children: [status, call.notice ? ` · ${call.notice}` : call.label === call.number ? "" : ` · ${call.number}`] })] }), live && (_jsxs(_Fragment, { children: [_jsx(Button, { isIconOnly: true, size: "sm", variant: "secondary", "aria-label": call.muted ? "Unmute" : "Mute", isDisabled: call.phase !== "in-call", onPress: onToggleMute, children: call.muted ? _jsx(MicOff, { className: "size-4" }) : _jsx(Mic, { className: "size-4" }) }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "danger", "aria-label": "Hang up", onPress: onHangUp, children: _jsx(PhoneOff, { className: "size-4" }) })] }))] }) }));
347
+ }
348
+ /** Seconds since `since`, ticking once a second. `0` while there is nothing to time. */
349
+ function useElapsedSeconds(since) {
350
+ const [now, setNow] = useState(() => Date.now());
351
+ useEffect(() => {
352
+ if (since === null)
353
+ return;
354
+ const timer = setInterval(() => setNow(Date.now()), 1_000);
355
+ return () => clearInterval(timer);
356
+ }, [since]);
357
+ return since === null ? 0 : Math.max(0, Math.round((now - since) / 1000));
358
+ }
@@ -162,6 +162,7 @@ function ConnectCheckoutStatusRow({ checkout, }) {
162
162
  return (_jsx(AnimatePresence, { mode: "wait", initial: false, children: checkout.status !== "idle" && (_jsx(m.div, { initial: { opacity: 0, y: 4 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -4 }, transition: { duration: 0.18 }, children: _jsx(CheckoutStatusRowContent, { checkout: checkout }) }, checkout.status)) }));
163
163
  }
164
164
  function CheckoutStatusRowContent({ checkout, }) {
165
+ const { openConnect } = useConnect();
165
166
  if (checkout.status === "waiting") {
166
167
  return (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-separator px-3 py-2 text-sm text-muted", children: [_jsx(Loader2, { className: "size-4 shrink-0 animate-spin", "aria-hidden": "true" }), _jsx("span", { children: "Finish up in the Connect window. This page updates on its own." })] }));
167
168
  }
@@ -172,7 +173,11 @@ function CheckoutStatusRowContent({ checkout, }) {
172
173
  // are fixed by going to the same URL - so offer it rather than explaining.
173
174
  return (_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3 rounded-lg border border-separator px-3 py-2 text-sm text-muted", children: [_jsx("span", { children: checkout.status === "blocked"
174
175
  ? "Your browser blocked the Connect window."
175
- : "Nothing changed - the Connect window closed before it was finished." }), _jsxs("div", { className: "flex items-center gap-2", children: [checkout.url && (_jsxs(Button, { variant: "secondary", onPress: () => window.open(checkout.url, "_blank", "noopener,noreferrer"), children: ["Open Connect", _jsx(ExternalLink, { className: "size-3.5" })] })), _jsx(Button, { variant: "tertiary", onPress: checkout.reset, children: "Dismiss" })] })] }));
176
+ : "Nothing changed - the Connect window closed before it was finished." }), _jsxs("div", { className: "flex items-center gap-2", children: [checkout.url && (_jsxs(Button, { variant: "secondary",
177
+ // Not watched by the checkout poll - that run has ended - but a
178
+ // hand-off all the same: Connect reports back when it is done, and
179
+ // the provider re-reads every resource on this page.
180
+ onPress: () => openConnect(checkout.url), children: ["Open Connect", _jsx(ExternalLink, { className: "size-3.5" })] })), _jsx(Button, { variant: "tertiary", onPress: checkout.reset, children: "Dismiss" })] })] }));
176
181
  }
177
182
  /**
178
183
  * Cancellation, kept out of the plan grid and stated plainly.
@@ -192,9 +197,10 @@ function CheckoutStatusRowContent({ checkout, }) {
192
197
  * version.
193
198
  */
194
199
  function ConnectUnsubscribeRow({ subscription, appUrl, checkout, }) {
200
+ const { openConnect } = useConnect();
195
201
  const endsOn = subscription.currentBillingCycle?.endDate;
196
202
  if (subscription.cancelAtPeriodEnd) {
197
- return (_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3 border-t border-separator pt-4", children: [_jsxs("p", { className: "text-sm text-muted", children: ["Cancels", endsOn ? ` on ${new Date(endsOn).toLocaleDateString()}` : " at the end of this cycle", ". You keep access until then."] }), _jsxs(Button, { variant: "tertiary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${subscription.id}`, "_blank", "noopener,noreferrer"), children: ["View on Connect", _jsx(ExternalLink, { className: "size-3.5" })] })] }));
203
+ return (_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3 border-t border-separator pt-4", children: [_jsxs("p", { className: "text-sm text-muted", children: ["Cancels", endsOn ? ` on ${new Date(endsOn).toLocaleDateString()}` : " at the end of this cycle", ". You keep access until then."] }), _jsxs(Button, { variant: "tertiary", onPress: () => openConnect(`/billing/subscriptions/${encodeURIComponent(subscription.id)}`), children: ["View on Connect", _jsx(ExternalLink, { className: "size-3.5" })] })] }));
198
204
  }
199
205
  return (_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3 border-t border-separator pt-4", children: [_jsx("p", { className: "text-sm text-muted", children: "Cancel any time. Access continues to the end of the paid cycle." }), _jsxs(Button, { variant: "tertiary", className: "text-danger", isDisabled: checkout.status === "waiting", onPress: () => checkout.start({
200
206
  url: buildCancelUrl({ appUrl, subscriptionId: subscription.id }),
@@ -68,9 +68,10 @@ export function ConnectReadinessBanner({ includeAdvisory = false, hideWhenReady
68
68
  return (_jsx(MotionProvider, { children: _jsx(m.div, { className: cn("flex flex-col gap-2", className), role: "status", initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: ENTER, children: blockers.map((blocker) => (_jsx(BlockerRow, { blocker: blocker }, `${blocker.code}-${blocker.message}`))) }) }));
69
69
  }
70
70
  function BlockerRow({ blocker }) {
71
+ const { openConnect } = useConnect();
71
72
  const inProgress = IN_PROGRESS_CODES.has(blocker.code);
72
73
  const isBlocking = blocker.severity === "blocking";
73
- return (_jsxs("div", { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm ${inProgress || !isBlocking ? "bg-surface text-muted" : "bg-danger-soft text-danger"}`, children: [_jsxs("span", { className: "flex items-center gap-2", children: [inProgress || !isBlocking ? (_jsx(Info, { className: "size-4 shrink-0", "aria-hidden": "true" })) : (_jsx(AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" })), blocker.message] }), blocker.resolveUrl && !inProgress && (_jsxs(Button, { variant: "secondary", onPress: () => window.open(blocker.resolveUrl, "_blank", "noopener,noreferrer"), children: [actionLabel(blocker), _jsx(ExternalLink, { className: "size-3.5" })] }))] }));
74
+ return (_jsxs("div", { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm ${inProgress || !isBlocking ? "bg-surface text-muted" : "bg-danger-soft text-danger"}`, children: [_jsxs("span", { className: "flex items-center gap-2", children: [inProgress || !isBlocking ? (_jsx(Info, { className: "size-4 shrink-0", "aria-hidden": "true" })) : (_jsx(AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" })), blocker.message] }), blocker.resolveUrl && !inProgress && (_jsxs(Button, { variant: "secondary", onPress: () => openConnect(blocker.resolveUrl), children: [actionLabel(blocker), _jsx(ExternalLink, { className: "size-3.5" })] }))] }));
74
75
  }
75
76
  function ReadyRow({ className }) {
76
77
  return (_jsx("p", { className: cn("text-sm text-muted", className), children: "This workspace is cleared to use every part of the application." }));
@@ -72,13 +72,13 @@ export function ConnectNoSubscriptionCard({ plansHref = DEFAULT_PLANS_HREF, titl
72
72
  * impure and re-renders wrong.
73
73
  */
74
74
  export function ConnectSubscriptionCard({ plansHref, className }) {
75
- const { appUrl } = useConnect();
75
+ const { openConnect } = useConnect();
76
76
  const { isLoading, detail, plan, state } = useSubscriptionState();
77
77
  if (isLoading)
78
78
  return _jsx(Skeleton, { className: cn("h-48 rounded-xl", className) });
79
79
  if (!detail || !plan)
80
80
  return _jsx(ConnectNoSubscriptionCard, { plansHref: plansHref, className: className });
81
- return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2 }, children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: plan.name }), _jsx(SubscriptionChip, { state: state })] }), _jsxs(Card.Description, { children: [formatPrice(plan.price), " + ", plan.gstRate, "% GST per", " ", intervalLabel(plan.billingInterval).per] })] }), _jsxs(Card.Content, { className: "flex flex-col gap-2 text-sm", children: [_jsx(NextEvent, { detail: detail, state: state }), _jsx(ConnectSeatUsage, { plan: plan, detail: detail })] }), _jsxs(Card.Footer, { className: "flex flex-wrap gap-2", children: [_jsxs(Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }), _jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Change plan" })] })] }) }) }));
81
+ return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2 }, children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: plan.name }), _jsx(SubscriptionChip, { state: state })] }), _jsxs(Card.Description, { children: [formatPrice(plan.price), " + ", plan.gstRate, "% GST per", " ", intervalLabel(plan.billingInterval).per] })] }), _jsxs(Card.Content, { className: "flex flex-col gap-2 text-sm", children: [_jsx(NextEvent, { detail: detail, state: state }), _jsx(ConnectSeatUsage, { plan: plan, detail: detail })] }), _jsxs(Card.Footer, { className: "flex flex-wrap gap-2", children: [_jsxs(Button, { variant: "secondary", onPress: () => openConnect(`/billing/subscriptions/${encodeURIComponent(detail.id)}`), children: ["Manage on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }), _jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Change plan" })] })] }) }) }));
82
82
  }
83
83
  function SubscriptionChip({ state }) {
84
84
  switch (state) {
@@ -149,11 +149,11 @@ export function ConnectSeatUsage({ plan, detail, className, }) {
149
149
  * actually cares about anyway.
150
150
  */
151
151
  export function ConnectTrialBanner({ plansHref, className }) {
152
- const { appUrl } = useConnect();
152
+ const { openConnect } = useConnect();
153
153
  const { detail, state } = useSubscriptionState();
154
154
  if (state !== "trialing" || !detail)
155
155
  return null;
156
- return (_jsxs("div", { className: cn("flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", className), children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(Clock, { className: "size-4 shrink-0", "aria-hidden": "true" }), "You're on a free trial", detail.nextBillingDate ? `. Your first charge is on ${formatDate(detail.nextBillingDate)}.` : "."] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Compare plans" }), _jsxs(Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage", _jsx(ExternalLink, { className: "size-3.5" })] })] })] }));
156
+ return (_jsxs("div", { className: cn("flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", className), children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(Clock, { className: "size-4 shrink-0", "aria-hidden": "true" }), "You're on a free trial", detail.nextBillingDate ? `. Your first charge is on ${formatDate(detail.nextBillingDate)}.` : "."] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Compare plans" }), _jsxs(Button, { variant: "secondary", onPress: () => openConnect(`/billing/subscriptions/${encodeURIComponent(detail.id)}`), children: ["Manage", _jsx(ExternalLink, { className: "size-3.5" })] })] })] }));
157
157
  }
158
158
  /**
159
159
  * Renders `children` for a subscribed workspace, and the "pick a plan" card for
@@ -3,7 +3,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Avatar, Button, Chip, Modal, Skeleton, ToggleButton, ToggleButtonGroup, } from "@heroui/react";
4
4
  import { ChevronsUpDown, CreditCard, ExternalLink, Laptop, LogOut, Moon, ReceiptText, Sun, UserRound, } from "lucide-react";
5
5
  import { useConnect } from "../provider.js";
6
- import { connectPortalUrl } from "../portal-links.js";
7
6
  import { ConnectMark } from "../mark.js";
8
7
  import { initials } from "./identity.js";
9
8
  import { ConnectAccountSessions } from "./account-sessions.js";
@@ -16,6 +15,12 @@ const THEME_OPTIONS = [
16
15
  { value: "dark", label: "Dark", icon: Moon },
17
16
  { value: "system", label: "System", icon: Laptop },
18
17
  ];
18
+ /**
19
+ * Connect's front page, which is a destination rather than a task - nothing to
20
+ * finish, so nothing to come back from. Every *task* destination here (Profile,
21
+ * Billing, Invoices) goes through `openConnect` instead, which brings the
22
+ * visitor back when they are done.
23
+ */
19
24
  function openExternal(url) {
20
25
  window.open(url, "_blank", "noopener,noreferrer");
21
26
  }
@@ -90,8 +95,8 @@ function subscriptionChip(subscription) {
90
95
  * 44%, and takes the tile down to ~88px while making its contents read
91
96
  * bigger, not smaller.
92
97
  */
93
- function DestinationTile({ icon, label, url, }) {
94
- return (_jsxs(Button, { variant: "tertiary", "aria-label": `${label} (opens in a new tab)`, className: `relative h-auto w-full flex-col items-center justify-center gap-2 rounded-xl border border-border ${PANEL_FILL} p-3 hover:border-accent hover:bg-surface-secondary`, onPress: () => openExternal(url), children: [_jsx(ExternalLink, { className: "absolute end-2 top-2 size-3.5 text-muted", "aria-hidden": "true" }), _jsx("span", { className: `flex ${MEDALLION} items-center justify-center rounded-xl bg-surface text-muted`, children: icon }), _jsx("span", { className: "text-xs font-medium text-foreground", children: label })] }));
98
+ function DestinationTile({ icon, label, onPress, newTab, }) {
99
+ return (_jsxs(Button, { variant: "tertiary", "aria-label": newTab ? `${label} (opens in a new tab)` : label, className: `relative h-auto w-full flex-col items-center justify-center gap-2 rounded-xl border border-border ${PANEL_FILL} p-3 hover:border-accent hover:bg-surface-secondary`, onPress: onPress, children: [newTab && _jsx(ExternalLink, { className: "absolute end-2 top-2 size-3.5 text-muted", "aria-hidden": "true" }), _jsx("span", { className: `flex ${MEDALLION} items-center justify-center rounded-xl bg-surface text-muted`, children: icon }), _jsx("span", { className: "text-xs font-medium text-foreground", children: label })] }));
95
100
  }
96
101
  /**
97
102
  * The account modal's contents - identity card, the three Connect
@@ -112,14 +117,14 @@ function DestinationTile({ icon, label, url, }) {
112
117
  * three navigations.
113
118
  */
114
119
  function ProfileSurface({ onSignOut, theme, onThemeChange, }) {
115
- const { session, signOut, appUrl, clientId } = useConnect();
120
+ const { session, signOut, appUrl, openConnect, handoffMode } = useConnect();
116
121
  const user = session?.user;
117
122
  if (!user)
118
123
  return null;
119
124
  const label = user.displayName ?? user.email ?? "Account";
120
125
  const role = session?.role ?? null;
121
126
  const planChip = subscriptionChip(session?.subscription ?? null);
122
- return (_jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { size: "md", scroll: "inside", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Account" }) }), _jsxs(Modal.Body, { className: "flex flex-col gap-5", children: [_jsxs("div", { className: `flex items-start gap-3 rounded-xl border border-border ${PANEL_FILL} p-4`, children: [_jsx(UserAvatar, { user: user, size: "lg" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: label }), user.email && _jsx("p", { className: "truncate text-xs text-muted", children: user.email }), _jsx("p", { className: "truncate text-xs text-muted", children: user.workspaceName }), (role || planChip || !user.emailVerified) && (_jsxs("div", { className: "mt-1.5 flex flex-wrap items-center gap-1.5", children: [role && (_jsx(Chip, { size: "sm", variant: "soft", color: "accent", children: humanizeRole(role) })), planChip, !user.emailVerified && (_jsx(Chip, { size: "sm", variant: "soft", color: "warning", children: "Email unverified" }))] }))] })] }), _jsxs("div", { className: "grid grid-cols-3 gap-2", children: [_jsx(DestinationTile, { icon: _jsx(UserRound, { className: ICON, "aria-hidden": "true" }), label: "Profile", url: connectPortalUrl(appUrl, clientId, "/profile") }), _jsx(DestinationTile, { icon: _jsx(CreditCard, { className: ICON, "aria-hidden": "true" }), label: "Billing", url: `${appUrl}/billing` }), _jsx(DestinationTile, { icon: _jsx(ConnectMark, { className: ICON }), label: "Connect", url: appUrl })] }), theme && onThemeChange && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "Appearance" }), _jsx(ToggleButtonGroup, { fullWidth: true, "aria-label": "Theme", selectionMode: "single", disallowEmptySelection: true, selectedKeys: [theme], onSelectionChange: (keys) => {
127
+ return (_jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { size: "md", scroll: "inside", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Account" }) }), _jsxs(Modal.Body, { className: "flex flex-col gap-5", children: [_jsxs("div", { className: `flex items-start gap-3 rounded-xl border border-border ${PANEL_FILL} p-4`, children: [_jsx(UserAvatar, { user: user, size: "lg" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: label }), user.email && _jsx("p", { className: "truncate text-xs text-muted", children: user.email }), _jsx("p", { className: "truncate text-xs text-muted", children: user.workspaceName }), (role || planChip || !user.emailVerified) && (_jsxs("div", { className: "mt-1.5 flex flex-wrap items-center gap-1.5", children: [role && (_jsx(Chip, { size: "sm", variant: "soft", color: "accent", children: humanizeRole(role) })), planChip, !user.emailVerified && (_jsx(Chip, { size: "sm", variant: "soft", color: "warning", children: "Email unverified" }))] }))] })] }), _jsxs("div", { className: "grid grid-cols-3 gap-2", children: [_jsx(DestinationTile, { icon: _jsx(UserRound, { className: ICON, "aria-hidden": "true" }), label: "Profile", onPress: () => openConnect("/profile"), newTab: handoffMode === "popup" }), _jsx(DestinationTile, { icon: _jsx(CreditCard, { className: ICON, "aria-hidden": "true" }), label: "Billing", onPress: () => openConnect("/billing"), newTab: handoffMode === "popup" }), _jsx(DestinationTile, { icon: _jsx(ConnectMark, { className: ICON }), label: "Connect", onPress: () => openExternal(appUrl), newTab: true })] }), theme && onThemeChange && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "Appearance" }), _jsx(ToggleButtonGroup, { fullWidth: true, "aria-label": "Theme", selectionMode: "single", disallowEmptySelection: true, selectedKeys: [theme], onSelectionChange: (keys) => {
123
128
  const [next] = [...keys];
124
129
  if (typeof next === "string")
125
130
  onThemeChange(next);
@@ -197,8 +202,8 @@ export function ConnectUserMenu({ compact = false, ...rest }) {
197
202
  * state, a settings page.
198
203
  */
199
204
  export function ConnectManageSubscriptionButton({ className }) {
200
- const { appUrl } = useConnect();
201
- return (_jsxs(Button, { variant: "secondary", className: className, onPress: () => openExternal(`${appUrl}/billing`), children: [_jsx(CreditCard, { className: "size-4 shrink-0" }), "Manage subscription", _jsx(ExternalLink, { className: "size-3.5 shrink-0 text-muted" })] }));
205
+ const { openConnect } = useConnect();
206
+ return (_jsxs(Button, { variant: "secondary", className: className, onPress: () => openConnect("/billing"), children: [_jsx(CreditCard, { className: "size-4 shrink-0" }), "Manage subscription", _jsx(ExternalLink, { className: "size-3.5 shrink-0 text-muted" })] }));
202
207
  }
203
208
  /**
204
209
  * Invoices and payment history, on Connect.
@@ -211,10 +216,10 @@ export function ConnectManageSubscriptionButton({ className }) {
211
216
  * version of a record that must have exactly one.
212
217
  */
213
218
  export function ConnectInvoicesButton({ className }) {
214
- const { session, appUrl } = useConnect();
219
+ const { session, openConnect } = useConnect();
215
220
  if (!session?.user)
216
221
  return null;
217
- return (_jsxs(Button, { variant: "tertiary", className: className, onPress: () => openExternal(`${appUrl}/billing/invoices`), children: [_jsx(ReceiptText, { className: "size-4 shrink-0" }), "Invoices", _jsx(ExternalLink, { className: "size-3.5 shrink-0 text-muted" })] }));
222
+ return (_jsxs(Button, { variant: "tertiary", className: className, onPress: () => openConnect("/billing/invoices"), children: [_jsx(ReceiptText, { className: "size-4 shrink-0" }), "Invoices", _jsx(ExternalLink, { className: "size-3.5 shrink-0 text-muted" })] }));
218
223
  }
219
224
  /**
220
225
  * The "Profile" action on its own - the third of the three Connect
@@ -226,10 +231,10 @@ export function ConnectInvoicesButton({ className }) {
226
231
  * and got no `return_to`, so the user had no way back.
227
232
  */
228
233
  export function ConnectProfileButton({ className }) {
229
- const { session, appUrl, clientId } = useConnect();
234
+ const { session, openConnect } = useConnect();
230
235
  if (!session?.user)
231
236
  return null;
232
- return (_jsxs(Button, { variant: "tertiary", className: className, onPress: () => openExternal(connectPortalUrl(appUrl, clientId, "/profile")), children: [_jsx(UserRound, { className: "size-4 shrink-0" }), "Profile", _jsx(ExternalLink, { className: "size-3.5 shrink-0 text-muted" })] }));
237
+ return (_jsxs(Button, { variant: "tertiary", className: className, onPress: () => openConnect("/profile"), children: [_jsx(UserRound, { className: "size-4 shrink-0" }), "Profile", _jsx(ExternalLink, { className: "size-3.5 shrink-0 text-muted" })] }));
233
238
  }
234
239
  /** A plain "Open Connect" button, for a footer or a help menu. */
235
240
  export function ConnectHomeButton({ className }) {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The hand-off contract with Connect's portal - the pure half, so the rules
3
+ * can be tested without a browser. `openConnect` (on `useConnect()`) is the
4
+ * half that touches `window`.
5
+ *
6
+ * Every time this SDK sends somebody to Connect for a task - a plan, a
7
+ * payment method, their profile - it appends three params, and Connect's
8
+ * portal brings them back when the task is done:
9
+ *
10
+ * | Param | |
11
+ * |---|---|
12
+ * | `client_id` | Names this application. Connect only honours a `return_to` whose origin is registered for that client. |
13
+ * | `return_to` | The page they left. |
14
+ * | `mode` | `popup` - Connect closes its window when done and posts the outcome to this page first. `redirect` - Connect navigates back here with `?connect_result=`. |
15
+ */
16
+ /** `MessageEvent.data.type` of the message Connect posts before closing a popup. */
17
+ export const CONNECT_HANDOFF_MESSAGE_TYPE = "connect:handoff";
18
+ /** The query param Connect adds when it redirects back. */
19
+ export const CONNECT_HANDOFF_RESULT_PARAM = "connect_result";
20
+ const RESULTS = ["completed", "cancelled", "closed"];
21
+ function isResult(value) {
22
+ return typeof value === "string" && RESULTS.includes(value);
23
+ }
24
+ /**
25
+ * `target` - a path (`"/billing"`) or an absolute Connect URL (a readiness
26
+ * blocker's `resolveUrl`) - with the hand-off params set.
27
+ *
28
+ * Throws for a URL on any other origin: these params tell Connect where to
29
+ * send somebody back, and stamping them onto a third party's URL would hand
30
+ * that party the page this visitor was on.
31
+ */
32
+ export function buildHandoffUrl(target, { appUrl, clientId, mode, returnTo }) {
33
+ const url = new URL(target, appUrl);
34
+ if (url.origin !== new URL(appUrl).origin) {
35
+ throw new Error(`openConnect only opens Connect URLs (${new URL(appUrl).origin}), not ${url.origin}.`);
36
+ }
37
+ if (clientId)
38
+ url.searchParams.set("client_id", clientId);
39
+ url.searchParams.set("return_to", returnTo);
40
+ url.searchParams.set("mode", mode);
41
+ // `next` is the older spelling Connect still honours, and a stale one left
42
+ // on a server-built URL would compete with `return_to` - so it goes.
43
+ url.searchParams.delete("next");
44
+ return url.toString();
45
+ }
46
+ /**
47
+ * The result a `message` event carries, or `null` when it is not Connect's
48
+ * hand-off message. The origin check is the whole security story: any page
49
+ * can `postMessage` this window, and only Connect's verdict may trigger work.
50
+ */
51
+ export function readHandoffMessage(event, appUrl) {
52
+ if (event.origin !== new URL(appUrl).origin)
53
+ return null;
54
+ const data = event.data;
55
+ if (!data || typeof data !== "object")
56
+ return null;
57
+ const { type, result } = data;
58
+ return type === CONNECT_HANDOFF_MESSAGE_TYPE && isResult(result) ? result : null;
59
+ }
60
+ /**
61
+ * The result a redirect-mode return carries in `href`, and `href` with it
62
+ * removed - so a reload does not report the same trip twice and the param
63
+ * does not end up in a link somebody shares.
64
+ */
65
+ export function takeHandoffResult(href) {
66
+ const url = new URL(href);
67
+ const result = url.searchParams.get(CONNECT_HANDOFF_RESULT_PARAM);
68
+ if (!isResult(result))
69
+ return null;
70
+ url.searchParams.delete(CONNECT_HANDOFF_RESULT_PARAM);
71
+ return { result, cleaned: url.toString() };
72
+ }