@cosmoops/connect-sdk 0.41.0 → 0.43.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 +99 -0
- package/dist/cjs/client/components/access-gate.js +18 -40
- package/dist/cjs/client/components/apps-grid.js +11 -3
- package/dist/cjs/client/components/ask-ai-errors.js +109 -0
- package/dist/cjs/client/components/ask-ai-turns.js +12 -0
- package/dist/cjs/client/components/ask-ai.js +75 -8
- package/dist/cjs/client/components/plans.js +8 -2
- package/dist/cjs/client/components/readiness.js +2 -1
- package/dist/cjs/client/components/subscription.js +4 -4
- package/dist/cjs/client/components/user-menu.js +16 -11
- package/dist/cjs/client/handoff.js +78 -0
- package/dist/cjs/client/index.js +9 -6
- package/dist/cjs/client/provider.js +75 -4
- package/dist/cjs/client/use-checkout.js +23 -16
- package/dist/cjs/server/client.js +5 -1
- package/dist/cjs/server/handler.js +4 -1
- package/dist/cjs/server/resources/calls.js +5 -2
- package/dist/cjs/server/resources/identity.js +12 -0
- package/dist/cjs/server/resources/whatsapp.js +58 -0
- package/dist/esm/client/components/access-gate.js +18 -40
- package/dist/esm/client/components/apps-grid.js +11 -3
- package/dist/esm/client/components/ask-ai-errors.js +102 -0
- package/dist/esm/client/components/ask-ai-turns.js +12 -0
- package/dist/esm/client/components/ask-ai.js +76 -9
- package/dist/esm/client/components/plans.js +8 -2
- package/dist/esm/client/components/readiness.js +2 -1
- package/dist/esm/client/components/subscription.js +4 -4
- package/dist/esm/client/components/user-menu.js +16 -11
- package/dist/esm/client/handoff.js +72 -0
- package/dist/esm/client/index.js +4 -3
- package/dist/esm/client/provider.js +75 -4
- package/dist/esm/client/use-checkout.js +23 -16
- package/dist/esm/server/client.js +5 -1
- package/dist/esm/server/handler.js +4 -1
- package/dist/esm/server/resources/calls.js +5 -2
- package/dist/esm/server/resources/identity.js +12 -0
- package/dist/esm/server/resources/whatsapp.js +54 -0
- package/dist/types/client/components/ask-ai-errors.d.ts +41 -0
- package/dist/types/client/components/ask-ai-turns.d.ts +5 -0
- package/dist/types/client/handoff.d.ts +62 -0
- package/dist/types/client/index.d.ts +3 -1
- package/dist/types/client/provider.d.ts +72 -5
- package/dist/types/client/use-checkout.d.ts +4 -1
- package/dist/types/server/client.d.ts +4 -1
- package/dist/types/server/resources/identity.d.ts +10 -0
- package/dist/types/server/resources/whatsapp.d.ts +44 -0
- package/dist/types/types.d.ts +102 -2
- package/package.json +1 -1
|
@@ -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,
|
|
94
|
-
return (_jsxs(Button, { variant: "tertiary", "aria-label": `${label} (opens in a new tab)
|
|
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,
|
|
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",
|
|
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 {
|
|
201
|
-
return (_jsxs(Button, { variant: "secondary", className: className, onPress: () =>
|
|
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,
|
|
219
|
+
const { session, openConnect } = useConnect();
|
|
215
220
|
if (!session?.user)
|
|
216
221
|
return null;
|
|
217
|
-
return (_jsxs(Button, { variant: "tertiary", className: className, onPress: () =>
|
|
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,
|
|
234
|
+
const { session, openConnect } = useConnect();
|
|
230
235
|
if (!session?.user)
|
|
231
236
|
return null;
|
|
232
|
-
return (_jsxs(Button, { variant: "tertiary", className: className, onPress: () =>
|
|
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
|
+
}
|
package/dist/esm/client/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { ConnectProvider, useConnect,
|
|
|
15
15
|
// For a component that must render on a page with no provider - see its own
|
|
16
16
|
// note. `<ConnectVisitorChat>` is the case it exists for.
|
|
17
17
|
useConnectOptional, useConnectUser, useConnectWorkspaceType, useConnectAudience, useConnectPlatformAccess, useConnectEntitlements, useConnectPlans, useConnectApps, useConnectCompanyProfile, useConnectAddonUsage, useConnectSessions, useConnectIntegrations, useConnectResource, CONNECT_APP_URL, } from "./provider.js";
|
|
18
|
+
export { CONNECT_HANDOFF_MESSAGE_TYPE, CONNECT_HANDOFF_RESULT_PARAM } from "./handoff.js";
|
|
18
19
|
// Re-exported from the root entry rather than kept there alone: it is a pure
|
|
19
20
|
// helper, but every caller is UI code, and making a component file reach two
|
|
20
21
|
// entry points for one function is friction with nothing behind it.
|
|
@@ -32,9 +33,9 @@ ConnectUserMenu, ConnectManageSubscriptionButton, ConnectInvoicesButton, Connect
|
|
|
32
33
|
// `ConnectUserMenu` was the only thing that could render it.
|
|
33
34
|
export { ConnectAccountSessions } from "./components/account-sessions.js";
|
|
34
35
|
// A link to *any* Connect portal page that offers a `"Back to <your app>"` way
|
|
35
|
-
// home
|
|
36
|
-
//
|
|
37
|
-
//
|
|
36
|
+
// home, for a host that needs a real `<a href>` (a server-rendered link, a
|
|
37
|
+
// middle-click). For a button, prefer `useConnect().openConnect`, which also
|
|
38
|
+
// brings the visitor back when the task is done.
|
|
38
39
|
export { connectPortalUrl, useConnectPortalUrl } from "./portal-links.js";
|
|
39
40
|
// The frame the rest of these components live inside. Exported next to the
|
|
40
41
|
// pieces it composes rather than in a section of its own - an integration
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { clearOneTapSuppression, suppressOneTapAfterSignOut } from "./components/one-tap-state.js";
|
|
5
|
+
import { ConnectError } from "../errors.js";
|
|
6
|
+
import { buildHandoffUrl, readHandoffMessage, takeHandoffResult, } from "./handoff.js";
|
|
5
7
|
export const CONNECT_APP_URL = "https://connect.cosmoops.com";
|
|
6
8
|
const ConnectContext = createContext(null);
|
|
7
9
|
/** Marks that this tab has already spent its one silent attempt - see `ConnectProviderProps.silentSignIn`. */
|
|
@@ -20,7 +22,7 @@ const SILENT_ATTEMPT_KEY = "connect_silent_attempted";
|
|
|
20
22
|
* </ConnectProvider>
|
|
21
23
|
* ```
|
|
22
24
|
*/
|
|
23
|
-
export function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri, appUrl = CONNECT_APP_URL, fetch: fetchImpl, silentSignIn = false, children, }) {
|
|
25
|
+
export function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri, appUrl = CONNECT_APP_URL, fetch: fetchImpl, silentSignIn = false, handoff = "popup", onHandoffResult, children, }) {
|
|
24
26
|
// One state cell, not three. `isLoading` is *derived* from "no result yet"
|
|
25
27
|
// rather than set - a `setIsLoading(true)` at the top of an effect is a
|
|
26
28
|
// synchronous setState inside that effect, which cascades an extra render
|
|
@@ -122,7 +124,17 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
122
124
|
});
|
|
123
125
|
if (!response.ok) {
|
|
124
126
|
const envelope = await response.json().catch(() => null);
|
|
125
|
-
|
|
127
|
+
// A `ConnectError` rather than a bare `Error`, so a caller can branch
|
|
128
|
+
// on the status and code - a signed-out 401 and a throttled 429 need
|
|
129
|
+
// different words in front of a person. Same message as before, so
|
|
130
|
+
// anything already reading it sees no change.
|
|
131
|
+
throw new ConnectError({
|
|
132
|
+
message: envelope?.error?.message ?? `Connect bridge returned ${response.status}`,
|
|
133
|
+
status: response.status,
|
|
134
|
+
code: typeof envelope?.error?.code === "string" ? envelope.error.code : "unknown",
|
|
135
|
+
endpoint: `POST ${path}`,
|
|
136
|
+
body: envelope,
|
|
137
|
+
});
|
|
126
138
|
}
|
|
127
139
|
return response;
|
|
128
140
|
}, [basePath, fetchImpl]);
|
|
@@ -172,6 +184,61 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
172
184
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
173
185
|
void refresh();
|
|
174
186
|
}, [refresh]);
|
|
187
|
+
/*
|
|
188
|
+
* Hand-offs to Connect - see `ConnectContextValue.openConnect`.
|
|
189
|
+
*
|
|
190
|
+
* The provider's own `clientId` prop wins; otherwise the bridge's, which is
|
|
191
|
+
* what makes this work for the common integration that never passes one.
|
|
192
|
+
*/
|
|
193
|
+
const handoffClientId = clientId || session?.clientId || "";
|
|
194
|
+
const openConnect = useCallback((target, options = {}) => {
|
|
195
|
+
const returnTo = window.location.href;
|
|
196
|
+
if (handoff === "popup") {
|
|
197
|
+
// Deliberately no `noopener`: with it, `window.open` returns `null`
|
|
198
|
+
// by specification and Connect gets no `opener` - so it could
|
|
199
|
+
// neither tell this page it finished nor close itself (a browser
|
|
200
|
+
// only lets script close a window script opened). The opener it is
|
|
201
|
+
// handed is this page, and cross-origin that permits navigating it
|
|
202
|
+
// and posting to it - neither of which Connect does uninvited.
|
|
203
|
+
const opened = window.open(buildHandoffUrl(target, { appUrl, clientId: handoffClientId, mode: "popup", returnTo }), options.name ?? "_blank", options.features);
|
|
204
|
+
if (opened)
|
|
205
|
+
return opened;
|
|
206
|
+
// Blocked. A same-tab trip still gets them there and back, where a
|
|
207
|
+
// "your browser blocked the window" note leaves them to sort it out.
|
|
208
|
+
}
|
|
209
|
+
window.location.assign(buildHandoffUrl(target, { appUrl, clientId: handoffClientId, mode: "redirect", returnTo }));
|
|
210
|
+
return null;
|
|
211
|
+
}, [appUrl, handoff, handoffClientId]);
|
|
212
|
+
// Latest callback without re-subscribing the listener on every render of a
|
|
213
|
+
// host that passes an inline function.
|
|
214
|
+
const onHandoffResultRef = useRef(onHandoffResult);
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
onHandoffResultRef.current = onHandoffResult;
|
|
217
|
+
});
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
// Back from a redirect-mode trip. The page is a fresh load, so every read
|
|
220
|
+
// is already fresh - this only reports the outcome and tidies the URL.
|
|
221
|
+
// `replaceState` rather than a router call: this package imports no
|
|
222
|
+
// router, and Next's App Router honours a native `replaceState`.
|
|
223
|
+
const returned = takeHandoffResult(window.location.href);
|
|
224
|
+
if (returned) {
|
|
225
|
+
window.history.replaceState(window.history.state, "", returned.cleaned);
|
|
226
|
+
onHandoffResultRef.current?.(returned.result);
|
|
227
|
+
}
|
|
228
|
+
// A popup reporting in before it closes. `readHandoffMessage` checks the
|
|
229
|
+
// origin, which is what stops any other window driving this.
|
|
230
|
+
const onMessage = (event) => {
|
|
231
|
+
const result = readHandoffMessage(event, appUrl);
|
|
232
|
+
if (!result)
|
|
233
|
+
return;
|
|
234
|
+
// Session-wide: the plan, the readiness, the seat count - whatever the
|
|
235
|
+
// trip changed, every Connect component on the page re-reads it.
|
|
236
|
+
void refresh();
|
|
237
|
+
onHandoffResultRef.current?.(result);
|
|
238
|
+
};
|
|
239
|
+
window.addEventListener("message", onMessage);
|
|
240
|
+
return () => window.removeEventListener("message", onMessage);
|
|
241
|
+
}, [appUrl, refresh]);
|
|
175
242
|
useEffect(() => {
|
|
176
243
|
// `silentPending` already encodes settled-and-signed-out, no prior
|
|
177
244
|
// attempt, and no bridge error - a failed bridge read says nothing about
|
|
@@ -293,8 +360,10 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
293
360
|
}
|
|
294
361
|
}, [fetchImpl, mutateResource, oneTapEndpoints, refresh]);
|
|
295
362
|
const value = useMemo(() => ({
|
|
296
|
-
clientId,
|
|
363
|
+
clientId: handoffClientId,
|
|
297
364
|
appUrl,
|
|
365
|
+
handoffMode: handoff,
|
|
366
|
+
openConnect,
|
|
298
367
|
session,
|
|
299
368
|
isLoading,
|
|
300
369
|
error,
|
|
@@ -308,8 +377,10 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
308
377
|
readOneTapHint,
|
|
309
378
|
oneTapSignIn,
|
|
310
379
|
}), [
|
|
311
|
-
|
|
380
|
+
handoffClientId,
|
|
312
381
|
appUrl,
|
|
382
|
+
handoff,
|
|
383
|
+
openConnect,
|
|
313
384
|
session,
|
|
314
385
|
isLoading,
|
|
315
386
|
error,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { readHandoffMessage } from "./handoff.js";
|
|
3
4
|
import { useConnect } from "./provider.js";
|
|
4
5
|
/** How often `/entitlements` is re-read while a checkout is open. */
|
|
5
6
|
const POLL_INTERVAL_MS = 3_000;
|
|
@@ -30,7 +31,7 @@ const CLOSE_GRACE_MS = 12_000;
|
|
|
30
31
|
* period rather than ending the run.
|
|
31
32
|
*/
|
|
32
33
|
export function useConnectCheckout() {
|
|
33
|
-
const { fetchResource, invalidateResource, refresh } = useConnect();
|
|
34
|
+
const { appUrl, fetchResource, invalidateResource, refresh, openConnect } = useConnect();
|
|
34
35
|
const [state, setState] = useState({ status: "idle", url: null });
|
|
35
36
|
// Everything the poll loop needs lives in a ref: it runs from a timer, and
|
|
36
37
|
// re-creating the loop whenever a piece of it changed identity would
|
|
@@ -51,20 +52,18 @@ export function useConnectCheckout() {
|
|
|
51
52
|
// an await is what pop-up blockers exist to stop, and it would be
|
|
52
53
|
// blocked in every browser regardless of the user's settings.
|
|
53
54
|
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
setState({ status: "blocked", url });
|
|
55
|
+
// Through `openConnect`, which stamps the way back on the URL: Connect
|
|
56
|
+
// then closes this window itself once the change lands, instead of
|
|
57
|
+
// leaving the customer on a billing page they have to close by hand.
|
|
58
|
+
// The handle is still what the grace period below watches.
|
|
59
|
+
//
|
|
60
|
+
// `null` means it did not open a window - the provider is in `redirect`
|
|
61
|
+
// mode, or the popup was blocked and it fell back to one - and this
|
|
62
|
+
// page is navigating away. Nothing to watch; the trip back is a fresh
|
|
63
|
+
// load that reads the new entitlement on its own.
|
|
64
|
+
const opened = openConnect(url, { name: "connect_checkout", features: "popup,width=520,height=780" });
|
|
65
|
+
if (!opened)
|
|
66
66
|
return;
|
|
67
|
-
}
|
|
68
67
|
run.current = {
|
|
69
68
|
url,
|
|
70
69
|
isSettled,
|
|
@@ -74,7 +73,7 @@ export function useConnectCheckout() {
|
|
|
74
73
|
generation: generation.current,
|
|
75
74
|
};
|
|
76
75
|
setState({ status: "waiting", url });
|
|
77
|
-
}, [stop]);
|
|
76
|
+
}, [openConnect, stop]);
|
|
78
77
|
useEffect(() => {
|
|
79
78
|
// One interval for the hook's whole life rather than one per run: it does
|
|
80
79
|
// nothing while `run.current` is null, and a run that starts from a press
|
|
@@ -130,11 +129,19 @@ export function useConnectCheckout() {
|
|
|
130
129
|
void poll();
|
|
131
130
|
};
|
|
132
131
|
document.addEventListener("visibilitychange", onVisible);
|
|
132
|
+
// Connect saying it is done is a stronger hint still - read now, before
|
|
133
|
+
// the window has even finished closing.
|
|
134
|
+
const onMessage = (event) => {
|
|
135
|
+
if (readHandoffMessage(event, appUrl))
|
|
136
|
+
void poll();
|
|
137
|
+
};
|
|
138
|
+
window.addEventListener("message", onMessage);
|
|
133
139
|
return () => {
|
|
134
140
|
clearInterval(interval);
|
|
135
141
|
document.removeEventListener("visibilitychange", onVisible);
|
|
142
|
+
window.removeEventListener("message", onMessage);
|
|
136
143
|
};
|
|
137
|
-
}, [fetchResource, invalidateResource, refresh, stop]);
|
|
144
|
+
}, [appUrl, fetchResource, invalidateResource, refresh, stop]);
|
|
138
145
|
return { ...state, start, reset };
|
|
139
146
|
}
|
|
140
147
|
/**
|
|
@@ -22,6 +22,7 @@ import { SessionsResource } from "./resources/sessions.js";
|
|
|
22
22
|
import { PlatformResource } from "./resources/platform.js";
|
|
23
23
|
import { VisitorResource } from "./resources/visitor.js";
|
|
24
24
|
import { PushResource } from "./resources/push.js";
|
|
25
|
+
import { WhatsAppResource } from "./resources/whatsapp.js";
|
|
25
26
|
export const DEFAULT_BASE_URL = "https://connect.cosmoops.com";
|
|
26
27
|
const APPLICATION_API_PREFIX = "/api/v1/application";
|
|
27
28
|
/**
|
|
@@ -125,6 +126,8 @@ export class ConnectClient {
|
|
|
125
126
|
* preferences; you supply a title, a body and a URL. See `PushResource`.
|
|
126
127
|
*/
|
|
127
128
|
push;
|
|
129
|
+
/** Your application's own approved WhatsApp templates, to a workspace's customers. */
|
|
130
|
+
whatsapp;
|
|
128
131
|
/**
|
|
129
132
|
* Relays one `/data/*` or `/kv/*` request verbatim - `createConnectHandler`'s
|
|
130
133
|
* store route and nothing else. See `storePassthrough`.
|
|
@@ -171,8 +174,9 @@ export class ConnectClient {
|
|
|
171
174
|
this.visitor = new VisitorResource(transport);
|
|
172
175
|
this.platform = new PlatformResource(transport);
|
|
173
176
|
this.push = new PushResource(transport);
|
|
177
|
+
this.whatsapp = new WhatsAppResource(transport);
|
|
174
178
|
this.storeRelay = (spec) => storePassthrough(transport, spec);
|
|
175
179
|
}
|
|
176
180
|
}
|
|
177
181
|
/** Kept in step with `package.json` by `sdk.test.ts`. */
|
|
178
|
-
export const SDK_VERSION = "0.
|
|
182
|
+
export const SDK_VERSION = "0.43.0";
|
|
@@ -408,6 +408,8 @@ export function createConnectHandler(options) {
|
|
|
408
408
|
* anyone who used it).
|
|
409
409
|
*/
|
|
410
410
|
const oneTapEndpoints = session.write ? client.identity.oneTapEndpoints() : null;
|
|
411
|
+
/** See `IdentityResource.publicClientId` - what lets the browser name this application on a hand-off. */
|
|
412
|
+
const clientId = client.identity.publicClientId();
|
|
411
413
|
const routes = {
|
|
412
414
|
"GET /session": async (request) => {
|
|
413
415
|
const token = await session.read(request);
|
|
@@ -429,9 +431,10 @@ export function createConnectHandler(options) {
|
|
|
429
431
|
// whose shape depends on the answer is a response consumers start
|
|
430
432
|
// testing for key presence instead of value.
|
|
431
433
|
oneTap: oneTapEndpoints,
|
|
434
|
+
clientId,
|
|
432
435
|
});
|
|
433
436
|
}
|
|
434
|
-
return json({ ...(await client.identity.getUser(token)), oneTap: oneTapEndpoints });
|
|
437
|
+
return json({ ...(await client.identity.getUser(token)), oneTap: oneTapEndpoints, clientId });
|
|
435
438
|
},
|
|
436
439
|
"GET /entitlements": async (request) => {
|
|
437
440
|
const { workspaceId } = await requireWorkspace(request);
|
|
@@ -57,8 +57,11 @@ export class CallsResource {
|
|
|
57
57
|
* All three need the `calls:place` scope **and** a signed-in member
|
|
58
58
|
* (`asUser`), whose own "Make calls" permission Connect checks. None works
|
|
59
59
|
* server-to-server: a call is placed by a person, from the workspace's own
|
|
60
|
-
* number
|
|
61
|
-
*
|
|
60
|
+
* number. It is recorded only when the workspace has switched recording on
|
|
61
|
+
* (the person called hears a notice); `call.analyzed` then follows with the
|
|
62
|
+
* summary, intent and action items. Read how one went with `get(callId)`,
|
|
63
|
+
* which needs `calls:read` - a call your application placed is readable by
|
|
64
|
+
* it, transcript included.
|
|
62
65
|
*/
|
|
63
66
|
/**
|
|
64
67
|
* Whether the member can call, and if not why - what `<ConnectCallButton>`
|
|
@@ -89,6 +89,18 @@ export class IdentityResource {
|
|
|
89
89
|
authorize.searchParams.set("client_id", this.clientId);
|
|
90
90
|
return { hintUrl: hint.toString(), authorizeUrl: authorize.toString() };
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* This client's public `client_id`, or `null` when none is configured.
|
|
94
|
+
*
|
|
95
|
+
* The bridge hands it to the browser on `/session` so `openConnect` can name
|
|
96
|
+
* the application on every hand-off without the host passing `clientId` to
|
|
97
|
+
* `<ConnectProvider>` too - Connect only honours a `return_to` it can match
|
|
98
|
+
* against *a client's* registered redirect origins. Public by definition:
|
|
99
|
+
* it already rides on every authorize URL.
|
|
100
|
+
*/
|
|
101
|
+
publicClientId() {
|
|
102
|
+
return this.clientId;
|
|
103
|
+
}
|
|
92
104
|
/** Step 2 - trade the one-time `code` for a session. Server-side only. */
|
|
93
105
|
async exchangeCode(input) {
|
|
94
106
|
const wire = await this.post("/sso/token", {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
/**
|
|
3
|
+
* Your application's own WhatsApp templates, sent to a workspace's customers
|
|
4
|
+
* through Connect's WhatsApp sender - no WhatsApp Business account of your
|
|
5
|
+
* own.
|
|
6
|
+
*
|
|
7
|
+
* Templates are written and submitted to Meta in Console under your
|
|
8
|
+
* application; you name them here by slug and Connect resolves
|
|
9
|
+
* `app_<yourApplicationKey>_<slug>` from your key, so a key can never send
|
|
10
|
+
* another application's message or one of Connect's own. There is no free
|
|
11
|
+
* text: outside a customer's 24-hour window WhatsApp permits nothing else.
|
|
12
|
+
*
|
|
13
|
+
* Server-to-server only - there is no browser path, by design.
|
|
14
|
+
*/
|
|
15
|
+
export class WhatsAppResource {
|
|
16
|
+
transport;
|
|
17
|
+
constructor(transport) {
|
|
18
|
+
this.transport = transport;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Send one template to one customer.
|
|
22
|
+
*
|
|
23
|
+
* Refusals arrive as a `ConnectError` whose `code` says what to tell the
|
|
24
|
+
* person who pressed Send - `template_not_approved`, `consent_required`,
|
|
25
|
+
* `opted_out`, `not_found`. None of them is a fault worth retrying.
|
|
26
|
+
*
|
|
27
|
+
* An `Idempotency-Key` is always sent, as on `otp.send`: a retried send
|
|
28
|
+
* without one is a second message on somebody's phone. Pass your own -
|
|
29
|
+
* derived from what is being sent, e.g. the quote number - to make a
|
|
30
|
+
* retry from a *later* request safe too.
|
|
31
|
+
*/
|
|
32
|
+
send(input, options = {}) {
|
|
33
|
+
return request(this.transport, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
path: "/whatsapp/messages",
|
|
36
|
+
json: input,
|
|
37
|
+
headers: { "idempotency-key": options.idempotencyKey ?? crypto.randomUUID() },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Whether a template would send right now, and the values it needs.
|
|
42
|
+
*
|
|
43
|
+
* Ask before offering WhatsApp on a screen: Meta's approval takes minutes
|
|
44
|
+
* to days, and an approved template can be paused later. A `ConnectError`
|
|
45
|
+
* with `status: 404` means your application has no such template.
|
|
46
|
+
*/
|
|
47
|
+
template(slug, options = {}) {
|
|
48
|
+
const language = options.language ? `?language=${encodeURIComponent(options.language)}` : "";
|
|
49
|
+
return request(this.transport, {
|
|
50
|
+
method: "GET",
|
|
51
|
+
path: `/whatsapp/templates/${encodeURIComponent(slug)}${language}`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Ask AI shows when something fails - a sentence for a person, never the
|
|
3
|
+
* error itself.
|
|
4
|
+
*
|
|
5
|
+
* The raw text is somebody else's diagnostic: `TypeError: Failed to fetch`
|
|
6
|
+
* from the browser, `Connect bridge returned 502` from the provider, a
|
|
7
|
+
* validation message naming a request field. None of it tells the person what
|
|
8
|
+
* happened or what to do, and the last one tells them something about the
|
|
9
|
+
* API they should not need to know. The real error is still on the console of
|
|
10
|
+
* whoever is debugging; this module decides only what is on screen.
|
|
11
|
+
*/
|
|
12
|
+
export declare const ASK_AI_GENERIC_FAILURE = "Something went wrong. Please try again.";
|
|
13
|
+
export declare const TOOL_GENERIC_FAILURE = "That step couldn't be completed. Try again in a moment.";
|
|
14
|
+
/**
|
|
15
|
+
* A failure Connect reported inside the stream, after it had opened.
|
|
16
|
+
*
|
|
17
|
+
* Kept apart from a transport failure because its text was written for the
|
|
18
|
+
* person by Connect's own stream error handler, so it may be shown as it is.
|
|
19
|
+
*/
|
|
20
|
+
export declare class AskAiStreamError extends Error {
|
|
21
|
+
readonly name = "AskAiStreamError";
|
|
22
|
+
}
|
|
23
|
+
export declare function describeAskAiFailure(cause: unknown): string;
|
|
24
|
+
/**
|
|
25
|
+
* Whether trying the same question again could help.
|
|
26
|
+
*
|
|
27
|
+
* Off for the failures a retry cannot fix - signed out, no access, Ask AI
|
|
28
|
+
* switched off - so the panel does not offer a button that will fail the same
|
|
29
|
+
* way every time.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isRetryableAskAiFailure(cause: unknown): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The failure a tool's *result* reports, or `null` when it succeeded.
|
|
34
|
+
*
|
|
35
|
+
* An application's tool answers `{ isError: true, ... }` rather than throwing,
|
|
36
|
+
* which is MCP's distinction between "the tool refused" and "the call broke".
|
|
37
|
+
* Connect puts its own sentence in `error.message` when it could not reach
|
|
38
|
+
* the application at all; otherwise the application's first text block is its
|
|
39
|
+
* own refusal ("You don't have permission to ..."), written for its own user.
|
|
40
|
+
*/
|
|
41
|
+
export declare function toolFailureFromOutput(output: unknown): string | null;
|
|
@@ -14,6 +14,11 @@ export type AskAiBlock = {
|
|
|
14
14
|
id: string;
|
|
15
15
|
name: string;
|
|
16
16
|
input: unknown | null;
|
|
17
|
+
/**
|
|
18
|
+
* Set when the step failed - a sentence for the person, never the raw
|
|
19
|
+
* error. See `toolFailureFromOutput`.
|
|
20
|
+
*/
|
|
21
|
+
failure?: string;
|
|
17
22
|
};
|
|
18
23
|
export interface AskAiTurn {
|
|
19
24
|
id: string;
|