@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,78 @@
1
+ "use strict";
2
+ /**
3
+ * The hand-off contract with Connect's portal - the pure half, so the rules
4
+ * can be tested without a browser. `openConnect` (on `useConnect()`) is the
5
+ * half that touches `window`.
6
+ *
7
+ * Every time this SDK sends somebody to Connect for a task - a plan, a
8
+ * payment method, their profile - it appends three params, and Connect's
9
+ * portal brings them back when the task is done:
10
+ *
11
+ * | Param | |
12
+ * |---|---|
13
+ * | `client_id` | Names this application. Connect only honours a `return_to` whose origin is registered for that client. |
14
+ * | `return_to` | The page they left. |
15
+ * | `mode` | `popup` - Connect closes its window when done and posts the outcome to this page first. `redirect` - Connect navigates back here with `?connect_result=`. |
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.CONNECT_HANDOFF_RESULT_PARAM = exports.CONNECT_HANDOFF_MESSAGE_TYPE = void 0;
19
+ exports.buildHandoffUrl = buildHandoffUrl;
20
+ exports.readHandoffMessage = readHandoffMessage;
21
+ exports.takeHandoffResult = takeHandoffResult;
22
+ /** `MessageEvent.data.type` of the message Connect posts before closing a popup. */
23
+ exports.CONNECT_HANDOFF_MESSAGE_TYPE = "connect:handoff";
24
+ /** The query param Connect adds when it redirects back. */
25
+ exports.CONNECT_HANDOFF_RESULT_PARAM = "connect_result";
26
+ const RESULTS = ["completed", "cancelled", "closed"];
27
+ function isResult(value) {
28
+ return typeof value === "string" && RESULTS.includes(value);
29
+ }
30
+ /**
31
+ * `target` - a path (`"/billing"`) or an absolute Connect URL (a readiness
32
+ * blocker's `resolveUrl`) - with the hand-off params set.
33
+ *
34
+ * Throws for a URL on any other origin: these params tell Connect where to
35
+ * send somebody back, and stamping them onto a third party's URL would hand
36
+ * that party the page this visitor was on.
37
+ */
38
+ function buildHandoffUrl(target, { appUrl, clientId, mode, returnTo }) {
39
+ const url = new URL(target, appUrl);
40
+ if (url.origin !== new URL(appUrl).origin) {
41
+ throw new Error(`openConnect only opens Connect URLs (${new URL(appUrl).origin}), not ${url.origin}.`);
42
+ }
43
+ if (clientId)
44
+ url.searchParams.set("client_id", clientId);
45
+ url.searchParams.set("return_to", returnTo);
46
+ url.searchParams.set("mode", mode);
47
+ // `next` is the older spelling Connect still honours, and a stale one left
48
+ // on a server-built URL would compete with `return_to` - so it goes.
49
+ url.searchParams.delete("next");
50
+ return url.toString();
51
+ }
52
+ /**
53
+ * The result a `message` event carries, or `null` when it is not Connect's
54
+ * hand-off message. The origin check is the whole security story: any page
55
+ * can `postMessage` this window, and only Connect's verdict may trigger work.
56
+ */
57
+ function readHandoffMessage(event, appUrl) {
58
+ if (event.origin !== new URL(appUrl).origin)
59
+ return null;
60
+ const data = event.data;
61
+ if (!data || typeof data !== "object")
62
+ return null;
63
+ const { type, result } = data;
64
+ return type === exports.CONNECT_HANDOFF_MESSAGE_TYPE && isResult(result) ? result : null;
65
+ }
66
+ /**
67
+ * The result a redirect-mode return carries in `href`, and `href` with it
68
+ * removed - so a reload does not report the same trip twice and the param
69
+ * does not end up in a link somebody shares.
70
+ */
71
+ function takeHandoffResult(href) {
72
+ const url = new URL(href);
73
+ const result = url.searchParams.get(exports.CONNECT_HANDOFF_RESULT_PARAM);
74
+ if (!isResult(result))
75
+ return null;
76
+ url.searchParams.delete(exports.CONNECT_HANDOFF_RESULT_PARAM);
77
+ return { result, cleaned: url.toString() };
78
+ }
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- 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.useConnectPortalUrl = exports.connectPortalUrl = 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.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 = exports.useConnectPush = exports.useActivePath = void 0;
6
- exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useUpdateEventType = exports.useCreateEventType = exports.useManageableEventTypes = exports.useUpdateAvailabilitySchedule = exports.useCreateAvailabilitySchedule = exports.useAvailabilitySchedules = exports.useBookableEventTypes = exports.ConnectEventTypeFormModal = exports.ConnectAvailabilityFormModal = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useCreateWorkspace = exports.ConnectCreateWorkspaceModal = 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 = exports.ConnectFilterTabs = exports.ConnectCursorPagination = void 0;
4
+ 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.useConnectPortalUrl = exports.connectPortalUrl = 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_HANDOFF_RESULT_PARAM = exports.CONNECT_HANDOFF_MESSAGE_TYPE = 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.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 = exports.useConnectPush = exports.useActivePath = exports.ConnectSideNav = exports.ConnectSidebarNav = void 0;
6
+ exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useUpdateEventType = exports.useCreateEventType = exports.useManageableEventTypes = exports.useUpdateAvailabilitySchedule = exports.useCreateAvailabilitySchedule = exports.useAvailabilitySchedules = exports.useBookableEventTypes = exports.ConnectEventTypeFormModal = exports.ConnectAvailabilityFormModal = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useConnectCall = exports.ConnectCallingStatus = exports.ConnectCallButton = exports.ConnectCallProvider = exports.useCreateWorkspace = exports.ConnectCreateWorkspaceModal = 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 = exports.ConnectFilterTabs = exports.ConnectCursorPagination = exports.ConnectConfirmDialog = exports.ConnectLinkButton = void 0;
7
7
  /**
8
8
  * Browser half of `@cosmoops/connect-sdk` - React components and hooks.
9
9
  *
@@ -35,6 +35,9 @@ Object.defineProperty(exports, "useConnectSessions", { enumerable: true, get: fu
35
35
  Object.defineProperty(exports, "useConnectIntegrations", { enumerable: true, get: function () { return provider_js_1.useConnectIntegrations; } });
36
36
  Object.defineProperty(exports, "useConnectResource", { enumerable: true, get: function () { return provider_js_1.useConnectResource; } });
37
37
  Object.defineProperty(exports, "CONNECT_APP_URL", { enumerable: true, get: function () { return provider_js_1.CONNECT_APP_URL; } });
38
+ var handoff_js_1 = require("./handoff.js");
39
+ Object.defineProperty(exports, "CONNECT_HANDOFF_MESSAGE_TYPE", { enumerable: true, get: function () { return handoff_js_1.CONNECT_HANDOFF_MESSAGE_TYPE; } });
40
+ Object.defineProperty(exports, "CONNECT_HANDOFF_RESULT_PARAM", { enumerable: true, get: function () { return handoff_js_1.CONNECT_HANDOFF_RESULT_PARAM; } });
38
41
  // Re-exported from the root entry rather than kept there alone: it is a pure
39
42
  // helper, but every caller is UI code, and making a component file reach two
40
43
  // entry points for one function is friction with nothing behind it.
@@ -69,10 +72,10 @@ Object.defineProperty(exports, "ConnectSignOutButton", { enumerable: true, get:
69
72
  // `ConnectUserMenu` was the only thing that could render it.
70
73
  var account_sessions_js_1 = require("./components/account-sessions.js");
71
74
  Object.defineProperty(exports, "ConnectAccountSessions", { enumerable: true, get: function () { return account_sessions_js_1.ConnectAccountSessions; } });
72
- // A link to *any* Connect portal page that offers a "Back to <your app>" way
73
- // home - what `ConnectProfileButton` builds for `/profile` internally, made
74
- // reusable for a host linking out to a different Connect page (e.g.
75
- // `/calendar/settings`) on its own.
75
+ // A link to *any* Connect portal page that offers a `"Back to <your app>"` way
76
+ // home, for a host that needs a real `<a href>` (a server-rendered link, a
77
+ // middle-click). For a button, prefer `useConnect().openConnect`, which also
78
+ // brings the visitor back when the task is done.
76
79
  var portal_links_js_1 = require("./portal-links.js");
77
80
  Object.defineProperty(exports, "connectPortalUrl", { enumerable: true, get: function () { return portal_links_js_1.connectPortalUrl; } });
78
81
  Object.defineProperty(exports, "useConnectPortalUrl", { enumerable: true, get: function () { return portal_links_js_1.useConnectPortalUrl; } });
@@ -271,6 +274,11 @@ var create_workspace_form_js_1 = require("./components/create-workspace-form.js"
271
274
  Object.defineProperty(exports, "ConnectCreateWorkspaceModal", { enumerable: true, get: function () { return create_workspace_form_js_1.ConnectCreateWorkspaceModal; } });
272
275
  var use_workspaces_js_1 = require("./use-workspaces.js");
273
276
  Object.defineProperty(exports, "useCreateWorkspace", { enumerable: true, get: function () { return use_workspaces_js_1.useCreateWorkspace; } });
277
+ var call_button_js_1 = require("./components/call-button.js");
278
+ Object.defineProperty(exports, "ConnectCallProvider", { enumerable: true, get: function () { return call_button_js_1.ConnectCallProvider; } });
279
+ Object.defineProperty(exports, "ConnectCallButton", { enumerable: true, get: function () { return call_button_js_1.ConnectCallButton; } });
280
+ Object.defineProperty(exports, "ConnectCallingStatus", { enumerable: true, get: function () { return call_button_js_1.ConnectCallingStatus; } });
281
+ Object.defineProperty(exports, "useConnectCall", { enumerable: true, get: function () { return call_button_js_1.useConnectCall; } });
274
282
  var booking_calendar_js_1 = require("./components/booking-calendar.js");
275
283
  Object.defineProperty(exports, "ConnectBookingCalendar", { enumerable: true, get: function () { return booking_calendar_js_1.ConnectBookingCalendar; } });
276
284
  var booking_dialogs_js_1 = require("./components/booking-dialogs.js");
@@ -10,7 +10,7 @@ const provider_js_1 = require("./provider.js");
10
10
  * `connectPortalUrl(appUrl, clientId, "/calendar/settings")`, and so on.
11
11
  *
12
12
  * Connect answers any of these paths for a signed-in user on the bare URL -
13
- * these two params only add the "Back to <your app>" banner
13
+ * these two params only add the `"Back to <your app>"` banner
14
14
  * (`ApplicationReturnBar` on Connect's side). It validates `return_to`
15
15
  * against this `client_id`'s registered redirect origins and silently drops
16
16
  * the banner if they don't match, so a wrong or stale `appUrl`/`clientId`
@@ -22,6 +22,8 @@ exports.useConnectResource = useConnectResource;
22
22
  const jsx_runtime_1 = require("react/jsx-runtime");
23
23
  const react_1 = require("react");
24
24
  const one_tap_state_js_1 = require("./components/one-tap-state.js");
25
+ const errors_js_1 = require("../errors.js");
26
+ const handoff_js_1 = require("./handoff.js");
25
27
  exports.CONNECT_APP_URL = "https://connect.cosmoops.com";
26
28
  const ConnectContext = (0, react_1.createContext)(null);
27
29
  /** Marks that this tab has already spent its one silent attempt - see `ConnectProviderProps.silentSignIn`. */
@@ -40,7 +42,7 @@ const SILENT_ATTEMPT_KEY = "connect_silent_attempted";
40
42
  * </ConnectProvider>
41
43
  * ```
42
44
  */
43
- function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri, appUrl = exports.CONNECT_APP_URL, fetch: fetchImpl, silentSignIn = false, children, }) {
45
+ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri, appUrl = exports.CONNECT_APP_URL, fetch: fetchImpl, silentSignIn = false, handoff = "popup", onHandoffResult, children, }) {
44
46
  // One state cell, not three. `isLoading` is *derived* from "no result yet"
45
47
  // rather than set - a `setIsLoading(true)` at the top of an effect is a
46
48
  // synchronous setState inside that effect, which cascades an extra render
@@ -142,7 +144,17 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
142
144
  });
143
145
  if (!response.ok) {
144
146
  const envelope = await response.json().catch(() => null);
145
- throw new Error(envelope?.error?.message ?? `Connect bridge returned ${response.status}`);
147
+ // A `ConnectError` rather than a bare `Error`, so a caller can branch
148
+ // on the status and code - a signed-out 401 and a throttled 429 need
149
+ // different words in front of a person. Same message as before, so
150
+ // anything already reading it sees no change.
151
+ throw new errors_js_1.ConnectError({
152
+ message: envelope?.error?.message ?? `Connect bridge returned ${response.status}`,
153
+ status: response.status,
154
+ code: typeof envelope?.error?.code === "string" ? envelope.error.code : "unknown",
155
+ endpoint: `POST ${path}`,
156
+ body: envelope,
157
+ });
146
158
  }
147
159
  return response;
148
160
  }, [basePath, fetchImpl]);
@@ -192,6 +204,61 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
192
204
  // eslint-disable-next-line react-hooks/set-state-in-effect
193
205
  void refresh();
194
206
  }, [refresh]);
207
+ /*
208
+ * Hand-offs to Connect - see `ConnectContextValue.openConnect`.
209
+ *
210
+ * The provider's own `clientId` prop wins; otherwise the bridge's, which is
211
+ * what makes this work for the common integration that never passes one.
212
+ */
213
+ const handoffClientId = clientId || session?.clientId || "";
214
+ const openConnect = (0, react_1.useCallback)((target, options = {}) => {
215
+ const returnTo = window.location.href;
216
+ if (handoff === "popup") {
217
+ // Deliberately no `noopener`: with it, `window.open` returns `null`
218
+ // by specification and Connect gets no `opener` - so it could
219
+ // neither tell this page it finished nor close itself (a browser
220
+ // only lets script close a window script opened). The opener it is
221
+ // handed is this page, and cross-origin that permits navigating it
222
+ // and posting to it - neither of which Connect does uninvited.
223
+ const opened = window.open((0, handoff_js_1.buildHandoffUrl)(target, { appUrl, clientId: handoffClientId, mode: "popup", returnTo }), options.name ?? "_blank", options.features);
224
+ if (opened)
225
+ return opened;
226
+ // Blocked. A same-tab trip still gets them there and back, where a
227
+ // "your browser blocked the window" note leaves them to sort it out.
228
+ }
229
+ window.location.assign((0, handoff_js_1.buildHandoffUrl)(target, { appUrl, clientId: handoffClientId, mode: "redirect", returnTo }));
230
+ return null;
231
+ }, [appUrl, handoff, handoffClientId]);
232
+ // Latest callback without re-subscribing the listener on every render of a
233
+ // host that passes an inline function.
234
+ const onHandoffResultRef = (0, react_1.useRef)(onHandoffResult);
235
+ (0, react_1.useEffect)(() => {
236
+ onHandoffResultRef.current = onHandoffResult;
237
+ });
238
+ (0, react_1.useEffect)(() => {
239
+ // Back from a redirect-mode trip. The page is a fresh load, so every read
240
+ // is already fresh - this only reports the outcome and tidies the URL.
241
+ // `replaceState` rather than a router call: this package imports no
242
+ // router, and Next's App Router honours a native `replaceState`.
243
+ const returned = (0, handoff_js_1.takeHandoffResult)(window.location.href);
244
+ if (returned) {
245
+ window.history.replaceState(window.history.state, "", returned.cleaned);
246
+ onHandoffResultRef.current?.(returned.result);
247
+ }
248
+ // A popup reporting in before it closes. `readHandoffMessage` checks the
249
+ // origin, which is what stops any other window driving this.
250
+ const onMessage = (event) => {
251
+ const result = (0, handoff_js_1.readHandoffMessage)(event, appUrl);
252
+ if (!result)
253
+ return;
254
+ // Session-wide: the plan, the readiness, the seat count - whatever the
255
+ // trip changed, every Connect component on the page re-reads it.
256
+ void refresh();
257
+ onHandoffResultRef.current?.(result);
258
+ };
259
+ window.addEventListener("message", onMessage);
260
+ return () => window.removeEventListener("message", onMessage);
261
+ }, [appUrl, refresh]);
195
262
  (0, react_1.useEffect)(() => {
196
263
  // `silentPending` already encodes settled-and-signed-out, no prior
197
264
  // attempt, and no bridge error - a failed bridge read says nothing about
@@ -313,8 +380,10 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
313
380
  }
314
381
  }, [fetchImpl, mutateResource, oneTapEndpoints, refresh]);
315
382
  const value = (0, react_1.useMemo)(() => ({
316
- clientId,
383
+ clientId: handoffClientId,
317
384
  appUrl,
385
+ handoffMode: handoff,
386
+ openConnect,
318
387
  session,
319
388
  isLoading,
320
389
  error,
@@ -328,8 +397,10 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
328
397
  readOneTapHint,
329
398
  oneTapSignIn,
330
399
  }), [
331
- clientId,
400
+ handoffClientId,
332
401
  appUrl,
402
+ handoff,
403
+ openConnect,
333
404
  session,
334
405
  isLoading,
335
406
  error,
@@ -5,6 +5,7 @@ exports.useConnectCheckout = useConnectCheckout;
5
5
  exports.buildCancelUrl = buildCancelUrl;
6
6
  exports.buildCheckoutUrl = buildCheckoutUrl;
7
7
  const react_1 = require("react");
8
+ const handoff_js_1 = require("./handoff.js");
8
9
  const provider_js_1 = require("./provider.js");
9
10
  /** How often `/entitlements` is re-read while a checkout is open. */
10
11
  const POLL_INTERVAL_MS = 3_000;
@@ -35,7 +36,7 @@ const CLOSE_GRACE_MS = 12_000;
35
36
  * period rather than ending the run.
36
37
  */
37
38
  function useConnectCheckout() {
38
- const { fetchResource, invalidateResource, refresh } = (0, provider_js_1.useConnect)();
39
+ const { appUrl, fetchResource, invalidateResource, refresh, openConnect } = (0, provider_js_1.useConnect)();
39
40
  const [state, setState] = (0, react_1.useState)({ status: "idle", url: null });
40
41
  // Everything the poll loop needs lives in a ref: it runs from a timer, and
41
42
  // re-creating the loop whenever a piece of it changed identity would
@@ -56,20 +57,18 @@ function useConnectCheckout() {
56
57
  // an await is what pop-up blockers exist to stop, and it would be
57
58
  // blocked in every browser regardless of the user's settings.
58
59
  //
59
- // No `noopener`: with it set, `window.open` returns `null` by
60
- // specification, and this hook would then read every successfully
61
- // opened checkout as blocked. The handle is the whole point - it is
62
- // how the grace period below knows the customer closed the window.
63
- // The window it hands an `opener` to is Connect's own origin, and
64
- // cross-origin access is limited to navigating this page, which
65
- // Connect does not do.
66
- const opened = window.open(url, "connect_checkout", "popup,width=520,height=780");
67
- if (!opened) {
68
- // Not a failure to report as an error: the customer can still get
69
- // there, they just have to click the link themselves.
70
- setState({ status: "blocked", url });
60
+ // Through `openConnect`, which stamps the way back on the URL: Connect
61
+ // then closes this window itself once the change lands, instead of
62
+ // leaving the customer on a billing page they have to close by hand.
63
+ // The handle is still what the grace period below watches.
64
+ //
65
+ // `null` means it did not open a window - the provider is in `redirect`
66
+ // mode, or the popup was blocked and it fell back to one - and this
67
+ // page is navigating away. Nothing to watch; the trip back is a fresh
68
+ // load that reads the new entitlement on its own.
69
+ const opened = openConnect(url, { name: "connect_checkout", features: "popup,width=520,height=780" });
70
+ if (!opened)
71
71
  return;
72
- }
73
72
  run.current = {
74
73
  url,
75
74
  isSettled,
@@ -79,7 +78,7 @@ function useConnectCheckout() {
79
78
  generation: generation.current,
80
79
  };
81
80
  setState({ status: "waiting", url });
82
- }, [stop]);
81
+ }, [openConnect, stop]);
83
82
  (0, react_1.useEffect)(() => {
84
83
  // One interval for the hook's whole life rather than one per run: it does
85
84
  // nothing while `run.current` is null, and a run that starts from a press
@@ -135,11 +134,19 @@ function useConnectCheckout() {
135
134
  void poll();
136
135
  };
137
136
  document.addEventListener("visibilitychange", onVisible);
137
+ // Connect saying it is done is a stronger hint still - read now, before
138
+ // the window has even finished closing.
139
+ const onMessage = (event) => {
140
+ if ((0, handoff_js_1.readHandoffMessage)(event, appUrl))
141
+ void poll();
142
+ };
143
+ window.addEventListener("message", onMessage);
138
144
  return () => {
139
145
  clearInterval(interval);
140
146
  document.removeEventListener("visibilitychange", onVisible);
147
+ window.removeEventListener("message", onMessage);
141
148
  };
142
- }, [fetchResource, invalidateResource, refresh, stop]);
149
+ }, [appUrl, fetchResource, invalidateResource, refresh, stop]);
143
150
  return { ...state, start, reset };
144
151
  }
145
152
  /**
@@ -25,6 +25,7 @@ const sessions_js_1 = require("./resources/sessions.js");
25
25
  const platform_js_1 = require("./resources/platform.js");
26
26
  const visitor_js_1 = require("./resources/visitor.js");
27
27
  const push_js_1 = require("./resources/push.js");
28
+ const whatsapp_js_1 = require("./resources/whatsapp.js");
28
29
  exports.DEFAULT_BASE_URL = "https://connect.cosmoops.com";
29
30
  const APPLICATION_API_PREFIX = "/api/v1/application";
30
31
  /**
@@ -128,6 +129,8 @@ class ConnectClient {
128
129
  * preferences; you supply a title, a body and a URL. See `PushResource`.
129
130
  */
130
131
  push;
132
+ /** Your application's own approved WhatsApp templates, to a workspace's customers. */
133
+ whatsapp;
131
134
  /**
132
135
  * Relays one `/data/*` or `/kv/*` request verbatim - `createConnectHandler`'s
133
136
  * store route and nothing else. See `storePassthrough`.
@@ -174,9 +177,10 @@ class ConnectClient {
174
177
  this.visitor = new visitor_js_1.VisitorResource(transport);
175
178
  this.platform = new platform_js_1.PlatformResource(transport);
176
179
  this.push = new push_js_1.PushResource(transport);
180
+ this.whatsapp = new whatsapp_js_1.WhatsAppResource(transport);
177
181
  this.storeRelay = (spec) => (0, data_js_1.storePassthrough)(transport, spec);
178
182
  }
179
183
  }
180
184
  exports.ConnectClient = ConnectClient;
181
185
  /** Kept in step with `package.json` by `sdk.test.ts`. */
182
- exports.SDK_VERSION = "0.40.0";
186
+ exports.SDK_VERSION = "0.42.0";
@@ -411,6 +411,8 @@ function createConnectHandler(options) {
411
411
  * anyone who used it).
412
412
  */
413
413
  const oneTapEndpoints = session.write ? client.identity.oneTapEndpoints() : null;
414
+ /** See `IdentityResource.publicClientId` - what lets the browser name this application on a hand-off. */
415
+ const clientId = client.identity.publicClientId();
414
416
  const routes = {
415
417
  "GET /session": async (request) => {
416
418
  const token = await session.read(request);
@@ -432,9 +434,10 @@ function createConnectHandler(options) {
432
434
  // whose shape depends on the answer is a response consumers start
433
435
  // testing for key presence instead of value.
434
436
  oneTap: oneTapEndpoints,
437
+ clientId,
435
438
  });
436
439
  }
437
- return json({ ...(await client.identity.getUser(token)), oneTap: oneTapEndpoints });
440
+ return json({ ...(await client.identity.getUser(token)), oneTap: oneTapEndpoints, clientId });
438
441
  },
439
442
  "GET /entitlements": async (request) => {
440
443
  const { workspaceId } = await requireWorkspace(request);
@@ -1213,6 +1216,67 @@ function createConnectHandler(options) {
1213
1216
  const items = await client.sessions.list(await requireToken(request));
1214
1217
  return json({ items });
1215
1218
  },
1219
+ /*
1220
+ * Browser calling - `<ConnectCallButton>`. Three fixed writes and no read:
1221
+ * how a call went is your *server's* question (`client.calls.get`), never
1222
+ * the browser's, so a call log can't be written from what a page claims.
1223
+ *
1224
+ * The workspace and the member both come from the session, as everywhere
1225
+ * here; the browser names only the number.
1226
+ */
1227
+ /*
1228
+ * The one calling *read*, and it is about the member, not about a call:
1229
+ * can they ring anybody from here. A refusal means they lack "Make calls"
1230
+ * - which is an answer, not a failure, so it is returned as one and the
1231
+ * button simply is not offered.
1232
+ */
1233
+ "GET /calls/availability": async (request) => {
1234
+ await requireWorkspace(request);
1235
+ const asUser = await requireToken(request);
1236
+ try {
1237
+ return json(await client.calls.getCallingAvailability({ asUser }));
1238
+ }
1239
+ catch (error) {
1240
+ if (error instanceof errors_js_1.ConnectError && error.status === 403) {
1241
+ return json(NO_CALLING_PERMISSION);
1242
+ }
1243
+ throw error;
1244
+ }
1245
+ },
1246
+ "POST /calls/browser-token": async (request) => {
1247
+ await requireWorkspace(request);
1248
+ const asUser = await requireToken(request);
1249
+ return json(await client.calls.getBrowserCallingToken({ asUser }));
1250
+ },
1251
+ "POST /calls/start": async (request) => {
1252
+ await requireWorkspace(request);
1253
+ const asUser = await requireToken(request);
1254
+ const body = (await request.json().catch(() => null));
1255
+ if (typeof body?.phone !== "string") {
1256
+ throw new errors_js_1.ConnectError({
1257
+ status: 400,
1258
+ code: "validation_error",
1259
+ message: "phone is required",
1260
+ endpoint: "/calls/start",
1261
+ });
1262
+ }
1263
+ return json(await client.calls.startBrowserCall({ phone: body.phone, asUser }), 201);
1264
+ },
1265
+ "POST /calls/cancel": async (request) => {
1266
+ await requireWorkspace(request);
1267
+ const asUser = await requireToken(request);
1268
+ const body = (await request.json().catch(() => null));
1269
+ if (typeof body?.callId !== "string") {
1270
+ throw new errors_js_1.ConnectError({
1271
+ status: 400,
1272
+ code: "validation_error",
1273
+ message: "callId is required",
1274
+ endpoint: "/calls/cancel",
1275
+ });
1276
+ }
1277
+ await client.calls.cancelBrowserCall({ callId: body.callId, asUser });
1278
+ return json({ cancelled: true });
1279
+ },
1216
1280
  "POST /sessions/revoke": async (request) => {
1217
1281
  const body = (await request.json().catch(() => null));
1218
1282
  if (!body?.sessionId) {
@@ -1498,6 +1562,18 @@ function createConnectHandler(options) {
1498
1562
  }
1499
1563
  };
1500
1564
  }
1565
+ /** What a member without "Make calls" is told - see `GET /calls/availability`. */
1566
+ const NO_CALLING_PERMISSION = {
1567
+ status: "no_permission",
1568
+ number: null,
1569
+ available: 0,
1570
+ availableMinutes: 0,
1571
+ ratePaisePerMinute: 0,
1572
+ lowBalance: false,
1573
+ canTopUp: false,
1574
+ canManageNumber: false,
1575
+ offer: null,
1576
+ };
1501
1577
  function json(body, status = 200) {
1502
1578
  return new Response(JSON.stringify(body), {
1503
1579
  status,
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CallsResource = void 0;
4
4
  const http_js_1 = require("../http.js");
5
+ const acting_as_js_1 = require("./acting-as.js");
5
6
  /**
6
7
  * Every call that arrived on a channel assigned to your application.
7
8
  *
@@ -53,5 +54,62 @@ class CallsResource {
53
54
  path: `/calls/${encodeURIComponent(callId)}`,
54
55
  });
55
56
  }
57
+ /*
58
+ * Browser calling - what `<ConnectCallButton>` runs on, through your bridge.
59
+ *
60
+ * All three need the `calls:place` scope **and** a signed-in member
61
+ * (`asUser`), whose own "Make calls" permission Connect checks. None works
62
+ * server-to-server: a call is placed by a person, from the workspace's own
63
+ * number, and is not recorded. Read how one went with `get(callId)`, which
64
+ * needs `calls:read` - a call your application placed is readable by it.
65
+ */
66
+ /**
67
+ * Whether the member can call, and if not why - what `<ConnectCallButton>`
68
+ * renders from. Throws `ConnectError` 403 for a member without "Make
69
+ * calls"; the bridge turns that into `status: "no_permission"`.
70
+ */
71
+ getCallingAvailability(input) {
72
+ return (0, http_js_1.request)(this.transport, {
73
+ method: "GET",
74
+ path: "/calls/availability",
75
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
76
+ });
77
+ }
78
+ /** A carrier token the member's browser signs in to the phone network with. */
79
+ getBrowserCallingToken(input) {
80
+ return (0, http_js_1.request)(this.transport, {
81
+ method: "POST",
82
+ path: "/calls/browser-token",
83
+ json: {},
84
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
85
+ });
86
+ }
87
+ /**
88
+ * Approves one call from the member to `phone` (E.164) and records it.
89
+ *
90
+ * Throws `ConnectError` with status `412` when the workspace has no active
91
+ * number, is not allowed to place calls, or the member is already on one -
92
+ * the message is written to be shown to them.
93
+ */
94
+ startBrowserCall(input) {
95
+ return (0, http_js_1.request)(this.transport, {
96
+ method: "POST",
97
+ path: "/calls/start",
98
+ json: { phone: input.phone },
99
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
100
+ });
101
+ }
102
+ /**
103
+ * Closes a call the browser never managed to place - recorded as
104
+ * `unanswered`/`cancelled`. A no-op once the carrier has it.
105
+ */
106
+ async cancelBrowserCall(input) {
107
+ await (0, http_js_1.request)(this.transport, {
108
+ method: "POST",
109
+ path: "/calls/cancel",
110
+ json: { callId: input.callId },
111
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
112
+ });
113
+ }
56
114
  }
57
115
  exports.CallsResource = CallsResource;
@@ -94,6 +94,18 @@ class IdentityResource {
94
94
  authorize.searchParams.set("client_id", this.clientId);
95
95
  return { hintUrl: hint.toString(), authorizeUrl: authorize.toString() };
96
96
  }
97
+ /**
98
+ * This client's public `client_id`, or `null` when none is configured.
99
+ *
100
+ * The bridge hands it to the browser on `/session` so `openConnect` can name
101
+ * the application on every hand-off without the host passing `clientId` to
102
+ * `<ConnectProvider>` too - Connect only honours a `return_to` it can match
103
+ * against *a client's* registered redirect origins. Public by definition:
104
+ * it already rides on every authorize URL.
105
+ */
106
+ publicClientId() {
107
+ return this.clientId;
108
+ }
97
109
  /** Step 2 - trade the one-time `code` for a session. Server-side only. */
98
110
  async exchangeCode(input) {
99
111
  const wire = await this.post("/sso/token", {
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WhatsAppResource = void 0;
4
+ const http_js_1 = require("../http.js");
5
+ /**
6
+ * Your application's own WhatsApp templates, sent to a workspace's customers
7
+ * through Connect's WhatsApp sender - no WhatsApp Business account of your
8
+ * own.
9
+ *
10
+ * Templates are written and submitted to Meta in Console under your
11
+ * application; you name them here by slug and Connect resolves
12
+ * `app_<yourApplicationKey>_<slug>` from your key, so a key can never send
13
+ * another application's message or one of Connect's own. There is no free
14
+ * text: outside a customer's 24-hour window WhatsApp permits nothing else.
15
+ *
16
+ * Server-to-server only - there is no browser path, by design.
17
+ */
18
+ class WhatsAppResource {
19
+ transport;
20
+ constructor(transport) {
21
+ this.transport = transport;
22
+ }
23
+ /**
24
+ * Send one template to one customer.
25
+ *
26
+ * Refusals arrive as a `ConnectError` whose `code` says what to tell the
27
+ * person who pressed Send - `template_not_approved`, `consent_required`,
28
+ * `opted_out`, `not_found`. None of them is a fault worth retrying.
29
+ *
30
+ * An `Idempotency-Key` is always sent, as on `otp.send`: a retried send
31
+ * without one is a second message on somebody's phone. Pass your own -
32
+ * derived from what is being sent, e.g. the quote number - to make a
33
+ * retry from a *later* request safe too.
34
+ */
35
+ send(input, options = {}) {
36
+ return (0, http_js_1.request)(this.transport, {
37
+ method: "POST",
38
+ path: "/whatsapp/messages",
39
+ json: input,
40
+ headers: { "idempotency-key": options.idempotencyKey ?? crypto.randomUUID() },
41
+ });
42
+ }
43
+ /**
44
+ * Whether a template would send right now, and the values it needs.
45
+ *
46
+ * Ask before offering WhatsApp on a screen: Meta's approval takes minutes
47
+ * to days, and an approved template can be paused later. A `ConnectError`
48
+ * with `status: 404` means your application has no such template.
49
+ */
50
+ template(slug, options = {}) {
51
+ const language = options.language ? `?language=${encodeURIComponent(options.language)}` : "";
52
+ return (0, http_js_1.request)(this.transport, {
53
+ method: "GET",
54
+ path: `/whatsapp/templates/${encodeURIComponent(slug)}${language}`,
55
+ });
56
+ }
57
+ }
58
+ exports.WhatsAppResource = WhatsAppResource;