@cosmoops/connect-sdk 0.19.0 → 0.21.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 (46) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +16 -2
  3. package/dist/cjs/client/components/apps-grid.js +112 -23
  4. package/dist/cjs/client/components/identity.js +24 -0
  5. package/dist/cjs/client/components/one-tap-state.js +96 -0
  6. package/dist/cjs/client/components/one-tap.js +106 -0
  7. package/dist/cjs/client/components/plan-pricing.js +16 -0
  8. package/dist/cjs/client/components/plans.js +1 -1
  9. package/dist/cjs/client/components/subscribe-dialog.js +2 -1
  10. package/dist/cjs/client/components/user-menu.js +2 -6
  11. package/dist/cjs/client/index.js +11 -3
  12. package/dist/cjs/client/provider.js +88 -0
  13. package/dist/cjs/server/client.js +9 -1
  14. package/dist/cjs/server/handler.js +126 -2
  15. package/dist/cjs/server/resources/calls.js +57 -0
  16. package/dist/cjs/server/resources/channels.js +115 -0
  17. package/dist/cjs/server/resources/identity.js +40 -0
  18. package/dist/esm/client/components/apps-grid.js +115 -26
  19. package/dist/esm/client/components/identity.js +21 -0
  20. package/dist/esm/client/components/one-tap-state.js +90 -0
  21. package/dist/esm/client/components/one-tap.js +103 -0
  22. package/dist/esm/client/components/plan-pricing.js +15 -0
  23. package/dist/esm/client/components/plans.js +3 -3
  24. package/dist/esm/client/components/subscribe-dialog.js +3 -2
  25. package/dist/esm/client/components/user-menu.js +1 -5
  26. package/dist/esm/client/index.js +7 -0
  27. package/dist/esm/client/provider.js +88 -0
  28. package/dist/esm/server/client.js +9 -1
  29. package/dist/esm/server/handler.js +127 -3
  30. package/dist/esm/server/resources/calls.js +53 -0
  31. package/dist/esm/server/resources/channels.js +111 -0
  32. package/dist/esm/server/resources/identity.js +39 -0
  33. package/dist/types/client/components/apps-grid.d.ts +14 -7
  34. package/dist/types/client/components/identity.d.ts +17 -0
  35. package/dist/types/client/components/one-tap-state.d.ts +39 -0
  36. package/dist/types/client/components/one-tap.d.ts +50 -0
  37. package/dist/types/client/components/plan-pricing.d.ts +13 -0
  38. package/dist/types/client/index.d.ts +4 -2
  39. package/dist/types/client/provider.d.ts +81 -0
  40. package/dist/types/server/client.d.ts +7 -1
  41. package/dist/types/server/index.d.ts +2 -0
  42. package/dist/types/server/resources/calls.d.ts +45 -0
  43. package/dist/types/server/resources/channels.d.ts +91 -0
  44. package/dist/types/server/resources/identity.d.ts +35 -0
  45. package/dist/types/types.d.ts +186 -1
  46. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,164 @@
3
3
  `@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
4
4
  versions may carry breaking changes** and patch versions never do.
5
5
 
6
+ ## 0.21.0
7
+
8
+ ### Added
9
+
10
+ - **`<ConnectOneTap />`** - a signed-out visitor who is already signed into
11
+ Connect is offered their account, and one tap signs them in **without leaving
12
+ the page**.
13
+
14
+ ```tsx
15
+ <ConnectProvider>
16
+ <App />
17
+ <ConnectOneTap />
18
+ </ConnectProvider>
19
+ ```
20
+
21
+ That is the whole integration. It needs no `clientId` in the browser and no
22
+ configuration: the bridge already knows the client, and now tells the page
23
+ where to ask on the `/session` read it was making anyway, so discovering One
24
+ Tap costs no extra request.
25
+
26
+ **What it replaces.** `silentSignIn` answers the same question - *is this
27
+ visitor already signed into Connect?* - by bouncing the browser to Connect
28
+ and back with `prompt=none`. That costs a visible full-page navigation on the
29
+ first load of **every** signed-out visit, shows the visitor nothing while it
30
+ happens, and gives them no way to decline. One Tap asks with a single
31
+ `fetch`, names the account before anything is signed, and completes with no
32
+ navigation at all. `silentSignIn` still works and is unchanged; prefer this.
33
+
34
+ **It only appears when it can help.** A visitor Connect does not recognise
35
+ sees nothing, ever - no prompt, no flicker, no redirect - so this is safe to
36
+ mount unconditionally, which `silentSignIn` never was.
37
+
38
+ **Requires your application to be same-site with Connect** - a
39
+ `cosmoops.com` subdomain. `SameSite` is judged on the registrable domain, so
40
+ the visitor's Connect cookie rides along from `crm.cosmoops.com` and from
41
+ nowhere else. Deployed off-site, the prompt simply never appears and
42
+ `ConnectLoginButton` behaves exactly as before. No third-party cookie is
43
+ involved at any point, so this works in every browser rather than only in
44
+ Chromium.
45
+
46
+ **Two prompts, because consent is not assumed.** A visitor who has authorised
47
+ your application before gets their name, email, workspace and photo. One who
48
+ has not gets an impersonal prompt whose tap leads to the real consent
49
+ screen - Connect releases no identifying detail at all until that has
50
+ happened once.
51
+
52
+ **Dismissal is remembered.** Closing the prompt buys 2 hours of quiet, then a
53
+ day, then a week; signing out buys a day. All in your own origin's
54
+ `localStorage`, and none of it leaves the browser.
55
+
56
+ - **`readOneTapHint()` and `oneTapSignIn()` on `useConnect()`** - the two calls
57
+ `<ConnectOneTap />` is built from, for applications that want to draw the
58
+ prompt themselves. Both report expected outcomes as values rather than
59
+ throwing: `consent_required` and `interaction_required` mean "hand off to the
60
+ interactive screen", which is a hand-off, not an error to show anybody.
61
+
62
+ - **`ConnectSession.oneTap`** - where the browser may ask Connect about the
63
+ visitor, or `null` when this deployment cannot run One Tap (no `clientId`, or
64
+ a session store with no `write`). Present on the signed-in and signed-out
65
+ branches alike, so no consumer has to test for key presence.
66
+
67
+ ### Security
68
+
69
+ - The One Tap exchange is **PKCE-bound, and the verifier never reaches the
70
+ page**. Connect hands the authorization code to the browser, so unlike the
71
+ redirect flow the code alone must not be redeemable: `/one-tap/start` mints
72
+ the pair and keeps the verifier in an `HttpOnly`, `SameSite=Lax` cookie on
73
+ your own origin, and `/one-tap/complete` is the only thing that can spend it.
74
+ A code lifted out of the browser, or replayed cross-site, is worthless.
75
+
76
+ ## 0.20.0
77
+
78
+ ### Added
79
+
80
+ - **`connect.channels` and `connect.calls`** - the phone numbers Connect has
81
+ assigned to your application, and the calls that arrived on them. Seven
82
+ endpoints that have been live on the API and reachable only by hand until
83
+ now.
84
+
85
+ ```ts
86
+ const { id } = await connect.channels.claim({ workspaceId });
87
+ await connect.channels.setRouting(id, { agent: "sales", afterHours: "voicemail" });
88
+
89
+ const { items } = await connect.calls.list({ channelId: id });
90
+ const { call, turns } = await connect.calls.get(items[0].id);
91
+ ```
92
+
93
+ **You never pick the number.** Inventory is a regulated asset bought under
94
+ Connect's carrier account, so `claim` takes a workspace and a kind and
95
+ Connect picks from a pool you cannot see - which is also why nothing here
96
+ exposes a carrier, a provider reference or a monthly rate. A workspace holds
97
+ exactly one number across every application it uses, and it is fixed for the
98
+ life of the engagement; only a Connect operator can unassign one. A `409`
99
+ means either "this workspace already has one" (fix the call) or "the pool is
100
+ empty right now" (retry - Connect is notified).
101
+
102
+ `setRouting`'s `destination` is an arbitrary JSON object Connect stores
103
+ verbatim and **never reads into**: which agent answers, business hours,
104
+ overflow and fallback are all your application's model. It replaces rather
105
+ than merges, and `clearRouting` is the only way to say "stop answering" -
106
+ overwriting a destination can only point the number somewhere else. Clearing
107
+ is idempotent and, unlike `setRouting`, is allowed on a suspended channel:
108
+ making a live number stop answering must never be the operation that is
109
+ unavailable.
110
+
111
+ `calls.list` filters on `channelId` and nothing else - each further filter
112
+ costs a Firestore composite index - and pages by cursor: **page until
113
+ `hasMore` is `false` rather than stopping on an empty `items`.**
114
+ `calls.get` adds the transcript, ordered by `sequence`, which is the sort to
115
+ trust since two turns can share a millisecond. `recordingFileId` and
116
+ `transcriptFileId` are Drive file ids readable with a `drive:read` key, and
117
+ are `null` until the carrier posts the audio - branch on `recordingStatus`
118
+ rather than reading `null` as a failure.
119
+
120
+ Scopes: `channels:read`, `channels:claim`, `channels:manage`, `calls:read`.
121
+ A channel or call belonging to another application answers `not_found`, not
122
+ `forbidden` - confirming it exists would make these reads an oracle for
123
+ enumerating another partner's ids.
124
+
125
+ ### Changed
126
+
127
+ - **The app launcher shows the whole catalogue, in two groups.**
128
+ `<ConnectAppsGrid>` now renders **Your apps** - what this workspace holds a
129
+ live subscription to - and **Explore**, the rest of the platform's active
130
+ applications. A tile in the first opens the application's own site; a tile in
131
+ the second opens that application's page on Connect, where a plan is chosen.
132
+ Your integration builds neither URL.
133
+
134
+ Tiles draw the application's registered icon (its uploaded brand image, or
135
+ its `lucide-react` icon from Connect's curated set) instead of a generic grid
136
+ glyph, the grid animates in, and a filter field appears once there are more
137
+ than eight tiles. The **"Browse all applications"** footer link is **gone**:
138
+ it pointed at `/applications` on Connect, a route that has never existed, so
139
+ the one discovery path the launcher offered was a 404. Discovery belongs in
140
+ the grid.
141
+
142
+ - **`GET /apps` answers with every active application**, not only the
143
+ subscribed ones. `WorkspaceApp` gains two fields:
144
+
145
+ ```ts
146
+ interface WorkspaceApp {
147
+ subscribed: boolean; // launchable now, vs. an invitation to subscribe
148
+ category: WorkspaceAppCategory;
149
+ // …unchanged
150
+ }
151
+ ```
152
+
153
+ `needsAttention` is always `false` when `subscribed` is `false`, and an
154
+ application whose subscription was **cancelled** now appears in the explore
155
+ half rather than disappearing. Applications this workspace's `type` is
156
+ outside the audience of are omitted entirely unless it already holds a
157
+ subscription to one - showing a tile whose only outcome is a refusal at
158
+ checkout is the dead end this change set out to remove.
159
+
160
+ **If you render your own grid from `connect.apps.list()`, filter on
161
+ `subscribed` to get the previous set back.** That is the only migration;
162
+ nothing was removed from the row.
163
+
6
164
  ## 0.19.0
7
165
 
8
166
  ### Added
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Official client for the **CosmoOps Connect application-partner API** (`/api/v1/application/*`).
4
4
 
5
- **Current version: `0.8.0`** · [Release notes](#release-notes) · [Full changelog](./CHANGELOG.md)
5
+ **Current version: `0.20.0`** · [Release notes](#release-notes) · [Full changelog](./CHANGELOG.md)
6
6
  — follows semver with the 0.x convention that a **minor** version may carry a
7
7
  breaking change (a patch never does). Read the release notes for your target
8
8
  version before upgrading past a minor bump.
@@ -343,6 +343,8 @@ try {
343
343
  | `billing` | `listPlans` · `getAddonUsage` · `consumeAddon` |
344
344
  | `drive` | `upload` · `getFileUrl` · `getPreviewUrl` |
345
345
  | `contacts` | `lookupByPhone` · `get` |
346
+ | `channels` | `list` · `get` · `claim` · `setRouting` · `clearRouting` |
347
+ | `calls` | `list` · `get` |
346
348
  | `scheduling` | `listEventTypes` · `findSlots` · `listBookings` · `createBooking` · `rescheduleBooking` · `cancelBooking` |
347
349
  | `ai` | `chat` · `extract` |
348
350
  | `otp` | `send` · `verify` |
@@ -597,7 +599,7 @@ import {
597
599
  | `ConnectUserButton` | The account row at the foot of the rail — circular avatar, name over email — opening a modal with identity, sessions and the Connect destinations. Profile, Manage subscription and Open Connect each open Connect in a new tab; Sign out acts in place. Renders nothing when signed out. (Was `ConnectUserMenu`.) |
598
600
  | `ConnectProfileModal` | The same modal with no trigger, driven by `isOpen`/`onOpenChange` — for an app keeping its own account control. |
599
601
  | `ConnectWorkspaceSwitcher` | Sits in the rail's header. Hides itself for an account with one workspace; shows unusable ones disabled, with the reason. |
600
- | `ConnectAppsGrid` | Google-style launcher — grid icon opening a modal of every app this workspace can open. |
602
+ | `ConnectAppsGrid` | Google-style launcher — grid icon opening a modal in two groups: **Your apps** (a live subscription, opened at the application's own site) and **Explore** (the rest of the catalogue, opened on Connect). Registered icons, filter field past eight tiles. |
601
603
  | `ConnectPlans` | Monthly/annual toggle, features, add-ons, live discount, coupon field, and the subscribe / upgrade / downgrade / cancel flow. |
602
604
  | `ConnectPlansPage` | A whole `/plans` page — readiness banner, current subscription, and the grid. What `connect-sdk init` scaffolds. |
603
605
  | `ConnectSubscribeDialog` | The review step `ConnectPlans` opens — plan, GST, what happens to the current cycle. Exported so you can drive the flow from your own pricing page. |
@@ -899,6 +901,18 @@ So the hand-off is a URL, and Connect renders it as a modal over the relevant pa
899
901
 
900
902
  Full history: [CHANGELOG.md](./CHANGELOG.md). Latest first:
901
903
 
904
+ ### 0.20.0
905
+
906
+ **The app launcher shows the whole catalogue.** `<ConnectAppsGrid>` renders two
907
+ groups — **Your apps** (a live subscription, opened at the application's own
908
+ site) and **Explore** (the rest of the platform's active applications, opened
909
+ on Connect). `GET /apps` answers accordingly, and `WorkspaceApp` gains
910
+ `subscribed` and `category`.
911
+
912
+ The "Browse all applications" footer link is gone: it pointed at a Connect
913
+ route that has never existed. **If you render your own grid from
914
+ `connect.apps.list()`, filter on `subscribed` to get the previous set back.**
915
+
902
916
  ### 0.12.0 — breaking
903
917
 
904
918
  The application shell is one column. **The top bar is gone**; the rail holds
@@ -5,44 +5,133 @@ exports.ConnectAppsGrid = ConnectAppsGrid;
5
5
  const jsx_runtime_1 = require("react/jsx-runtime");
6
6
  const react_1 = require("@heroui/react");
7
7
  const lucide_react_1 = require("lucide-react");
8
+ const react_2 = require("react");
8
9
  const provider_js_1 = require("../provider.js");
9
10
  const motion_js_1 = require("./motion.js");
10
11
  const shell_context_js_1 = require("./shell-context.js");
11
- function AppTile({ app }) {
12
- const disabled = app.launchUrl === null;
13
- const body = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("span", { className: "relative flex size-12 items-center justify-center overflow-hidden rounded-xl bg-surface-secondary", children: [app.iconUrl ? (
14
- // Deliberately a plain `<img>`: this package ships into any React app
15
- // and cannot assume `next/image` or any other framework loader.
16
- // eslint-disable-next-line @next/next/no-img-element
17
- (0, jsx_runtime_1.jsx)("img", { src: app.iconUrl, alt: "", className: "size-full object-cover" })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.LayoutGrid, { className: "size-6 text-muted", "aria-hidden": "true" })), app.needsAttention && ((0, jsx_runtime_1.jsx)("span", { className: "absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-warning text-warning-foreground", children: (0, jsx_runtime_1.jsx)(lucide_react_1.AlertCircle, { className: "size-3", "aria-hidden": "true" }) }))] }), (0, jsx_runtime_1.jsx)("span", { className: "line-clamp-2 text-center text-xs font-medium text-foreground", children: app.name }), app.isCurrent && (0, jsx_runtime_1.jsx)("span", { className: "text-[0.625rem] uppercase tracking-wide text-accent", children: "Current" })] }));
18
- const shared = "flex flex-col items-center gap-2 rounded-xl p-3 transition-colors focus-visible:outline-2 focus-visible:outline-focus";
12
+ /**
13
+ * Connect's curated `Application.icon` set, as components.
14
+ *
15
+ * A closed map rather than a dynamic lookup on `lucide-react`: the icon name
16
+ * arrives over the wire, and resolving an arbitrary string against a module's
17
+ * exports is how a catalog typo becomes a render crash. Anything unrecognised
18
+ * - including the older, lowercased names some applications were registered
19
+ * with - falls through to the neutral tile glyph.
20
+ */
21
+ const APP_ICONS = {
22
+ Mail: lucide_react_1.Mail,
23
+ MessageSquare: lucide_react_1.MessageSquare,
24
+ Database: lucide_react_1.Database,
25
+ HardDrive: lucide_react_1.HardDrive,
26
+ BarChart3: lucide_react_1.BarChart3,
27
+ LineChart: lucide_react_1.LineChart,
28
+ ShieldCheck: lucide_react_1.ShieldCheck,
29
+ Lock: lucide_react_1.Lock,
30
+ CreditCard: lucide_react_1.CreditCard,
31
+ Receipt: lucide_react_1.Receipt,
32
+ ListChecks: lucide_react_1.ListChecks,
33
+ Calendar: lucide_react_1.Calendar,
34
+ Server: lucide_react_1.Server,
35
+ Cloud: lucide_react_1.Cloud,
36
+ Cpu: lucide_react_1.Cpu,
37
+ Package: lucide_react_1.Package,
38
+ };
39
+ /** Below this many tiles a filter field is one more thing to look past. */
40
+ const SEARCH_THRESHOLD = 8;
41
+ /**
42
+ * The tile's own glyph: the uploaded brand icon when there is one, the
43
+ * curated lucide icon when there is not, and the neutral grid mark when the
44
+ * name is one this version of the SDK does not know.
45
+ */
46
+ function AppGlyph({ app }) {
47
+ if (app.iconUrl) {
48
+ // Deliberately a plain `<img>`: this package ships into any React app
49
+ // and cannot assume `next/image` or any other framework loader.
50
+ // eslint-disable-next-line @next/next/no-img-element
51
+ return (0, jsx_runtime_1.jsx)("img", { src: app.iconUrl, alt: "", className: "size-full object-cover" });
52
+ }
53
+ const Icon = APP_ICONS[app.icon] ?? lucide_react_1.LayoutGrid;
54
+ return (0, jsx_runtime_1.jsx)(Icon, { className: "size-6", "aria-hidden": "true" });
55
+ }
56
+ function AppTile({ app, onOpen }) {
57
+ // A subscribed app with no registered site is the one genuinely dead tile:
58
+ // there is nothing to open and nothing to buy.
59
+ const disabled = app.subscribed && app.launchUrl === null;
60
+ const shared = "group/tile flex w-full flex-col items-center gap-2 rounded-2xl p-3 text-center outline-offset-2 transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-focus";
61
+ const body = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("span", { className: "relative flex size-14 items-center justify-center overflow-hidden rounded-[0.8125rem] bg-surface-secondary text-muted transition-colors duration-200 group-hover/tile:bg-default group-hover/tile:text-foreground", children: [(0, jsx_runtime_1.jsx)(AppGlyph, { app: app }), app.needsAttention && ((0, jsx_runtime_1.jsx)("span", { className: "absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-warning text-warning-foreground", children: (0, jsx_runtime_1.jsx)(lucide_react_1.AlertCircle, { className: "size-3", "aria-hidden": "true" }) })), !app.subscribed && ((0, jsx_runtime_1.jsx)("span", { className: "absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-default text-muted opacity-0 transition-opacity duration-200 group-hover/tile:opacity-100", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { className: "size-3", "aria-hidden": "true" }) }))] }), (0, jsx_runtime_1.jsx)("span", { className: "line-clamp-2 text-xs font-medium leading-tight text-foreground", children: app.name }), app.isCurrent ? ((0, jsx_runtime_1.jsx)("span", { className: "text-[0.625rem] uppercase tracking-wide text-accent", children: "Current" })) : (!app.subscribed && ((0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wide text-muted opacity-0 transition-opacity duration-200 group-hover/tile:opacity-100", children: ["Subscribe", (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowUpRight, { className: "size-2.5", "aria-hidden": "true" })] })))] }));
19
62
  if (disabled) {
20
63
  return ((0, jsx_runtime_1.jsx)("div", { className: `${shared} cursor-default opacity-60`, title: `${app.name} has no launch URL configured`, children: body }));
21
64
  }
22
- return ((0, jsx_runtime_1.jsx)("a", { href: app.launchUrl, target: "_blank", rel: "noopener noreferrer", className: `${shared} hover:bg-surface-secondary`, title: app.description || app.name, children: body }));
65
+ return (
66
+ // A `<button>` rather than an `<a>`: where the tile goes depends on
67
+ // `subscribed` *and* on Connect's own origin, which only the provider
68
+ // knows, so one handler resolves it rather than every caller rebuilding
69
+ // the URL. Both destinations still open in a new tab - a launcher must
70
+ // not throw away the page you launched it from.
71
+ (0, jsx_runtime_1.jsx)(motion_js_1.m.button, { type: "button", onClick: () => onOpen(app), className: `${shared} hover:bg-surface-secondary`, title: app.description || app.name, whileHover: { y: -2 }, whileTap: { scale: 0.94 }, transition: { type: "spring", stiffness: 420, damping: 28 }, children: body }));
72
+ }
73
+ function AppsSection({ title, hint, apps, offset, onOpen, }) {
74
+ if (apps.length === 0)
75
+ return null;
76
+ return ((0, jsx_runtime_1.jsxs)("section", { className: "space-y-2", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-baseline justify-between gap-2 px-1", children: [(0, jsx_runtime_1.jsx)("h3", { className: "text-[0.6875rem] font-semibold uppercase tracking-[0.06em] text-muted", children: title }), (0, jsx_runtime_1.jsx)("p", { className: "text-[0.6875rem] text-muted", children: hint })] }), (0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-3 gap-1 sm:grid-cols-4", children: apps.map((app, index) => ((0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0, scale: 0.9, y: 8 }, animate: { opacity: 1, scale: 1, y: 0 }, transition: {
77
+ type: "spring",
78
+ stiffness: 420,
79
+ damping: 30,
80
+ // Capped so a large catalog does not make the last tile wait.
81
+ delay: Math.min(offset + index, 14) * 0.018,
82
+ }, children: (0, jsx_runtime_1.jsx)(AppTile, { app: app, onOpen: onOpen }) }, app.applicationId))) })] }));
83
+ }
84
+ function GridSkeleton() {
85
+ return ((0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-3 gap-1 sm:grid-cols-4", "aria-hidden": "true", children: Array.from({ length: 8 }, (_, index) => ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col items-center gap-2 p-3", children: [(0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: "size-14 rounded-[0.8125rem]" }), (0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: "h-3 w-14" })] }, index))) }));
23
86
  }
24
87
  /**
25
- * The Google-style app launcher: a grid icon in your top bar that opens a
26
- * modal of every Connect application this workspace can actually open.
88
+ * The Google-style app launcher: a grid icon that opens every Connect
89
+ * application this workspace has, and every one it could have.
90
+ *
91
+ * Two sections, because they are two different actions. **Your apps** is what
92
+ * the workspace can open right now - the current application pinned first and
93
+ * marked, so the grid never has a hole where "you are here" should be, and a
94
+ * paused subscription still listed but badged, because a dunning cycle should
95
+ * not make a product look deleted. **Explore** is the rest of the active
96
+ * catalog, minus anything this workspace's type could never subscribe to, and
97
+ * a tile there opens that application's page on Connect.
27
98
  *
28
- * Only launchable apps appear, so no tile is a dead end - a paused
29
- * subscription still shows, badged, because a dunning cycle should not make a
30
- * product look deleted. The current app is pinned first and marked, rather
31
- * than filtered out, so the grid never has a hole where "you are here" should
32
- * be.
99
+ * It used to show subscriptions only, with a "Browse all applications" link
100
+ * under the grid pointing at a Connect route that does not exist - so a
101
+ * workspace with one subscription saw one tile and had no working way to find
102
+ * anything else. Discovery belongs in the grid.
33
103
  *
34
104
  * Renders nothing when signed out: a launcher with nothing to launch is just a
35
105
  * button that opens an empty box.
36
106
  */
37
107
  function ConnectAppsGrid({ className, label = "Switch app", variant = "row" }) {
38
- const { session, isLoading: sessionLoading } = (0, provider_js_1.useConnect)();
108
+ const { session, isLoading: sessionLoading, appUrl } = (0, provider_js_1.useConnect)();
39
109
  const { data, isLoading, error } = (0, provider_js_1.useConnectApps)();
110
+ const [query, setQuery] = (0, react_2.useState)("");
111
+ const apps = (0, react_2.useMemo)(() => data?.apps ?? [], [data]);
112
+ const { mine, explore } = (0, react_2.useMemo)(() => {
113
+ const needle = query.trim().toLowerCase();
114
+ const matches = needle
115
+ ? apps.filter((app) => app.name.toLowerCase().includes(needle) || app.description.toLowerCase().includes(needle))
116
+ : apps;
117
+ return {
118
+ mine: matches.filter((app) => app.subscribed),
119
+ explore: matches.filter((app) => !app.subscribed),
120
+ };
121
+ }, [apps, query]);
40
122
  if (sessionLoading || !session?.user)
41
123
  return null;
42
- const apps = data?.apps ?? [];
43
- return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)(react_1.Modal, { children: [variant === "row" ? ((0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "tertiary", "aria-label": label, className: (0, shell_context_js_1.sidebarRowClass)({ className }), children: [(0, jsx_runtime_1.jsx)("span", { className: "flex size-5 shrink-0 items-center justify-center", children: (0, jsx_runtime_1.jsx)(lucide_react_1.LayoutGrid, { className: "size-5" }) }), (0, jsx_runtime_1.jsx)(shell_context_js_1.SidebarLabel, { children: label })] })) : ((0, jsx_runtime_1.jsx)(react_1.Button, { isIconOnly: true, variant: "primary", "aria-label": label, className: className, children: (0, jsx_runtime_1.jsx)(lucide_react_1.LayoutGrid, { className: "size-5" }) })), (0, jsx_runtime_1.jsx)(react_1.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_1.Modal.Dialog, { className: "sm:max-w-md", children: [(0, jsx_runtime_1.jsx)(react_1.Modal.CloseTrigger, {}), (0, jsx_runtime_1.jsx)(react_1.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Heading, { children: "Your apps" }) }), (0, jsx_runtime_1.jsxs)(react_1.Modal.Body, { children: [isLoading && ((0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-3 gap-2", children: Array.from({ length: 6 }, (_, index) => ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col items-center gap-2 p-3", children: [(0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: "size-12 rounded-xl" }), (0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: "h-3 w-14" })] }, index))) })), !isLoading && error && ((0, jsx_runtime_1.jsx)("p", { className: "py-6 text-center text-sm text-danger", children: "Could not load your apps." })), !isLoading && !error && apps.length === 0 && ((0, jsx_runtime_1.jsx)("p", { className: "py-6 text-center text-sm text-muted", children: "No other apps yet. Anything this workspace subscribes to shows up here." })), !isLoading && !error && apps.length > 0 && ((0, jsx_runtime_1.jsx)(motion_js_1.m.div, { className: "grid grid-cols-3 gap-2", variants: { show: { transition: { staggerChildren: 0.04 } } }, initial: "hidden", animate: "show", children: apps.map((app) => ((0, jsx_runtime_1.jsx)(motion_js_1.m.div, { variants: { hidden: { opacity: 0, y: 8 }, show: { opacity: 1, y: 0, transition: motion_js_1.ENTER } }, children: (0, jsx_runtime_1.jsx)(AppTile, { app: app }) }, app.applicationId))) }))] }), (0, jsx_runtime_1.jsx)(react_1.Modal.Footer, { children: (0, jsx_runtime_1.jsx)(ConnectBrowseAllLink, {}) })] }) }) })] }) }));
44
- }
45
- function ConnectBrowseAllLink() {
46
- const { appUrl } = (0, provider_js_1.useConnect)();
47
- return ((0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "tertiary", className: "w-full", onPress: () => window.open(`${appUrl}/applications`, "_blank", "noopener,noreferrer"), children: ["Browse all applications", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5 text-muted" })] }));
124
+ const openApp = (app) => {
125
+ // Subscribed goes to the partner's own site; everything else goes to that
126
+ // application's page on Connect, which is where a plan is actually
127
+ // chosen. The SDK owns this mapping so no integration has to know either
128
+ // URL shape.
129
+ const target = app.subscribed && app.launchUrl ? app.launchUrl : `${appUrl}/apps/${app.applicationId}`;
130
+ window.open(target, "_blank", "noopener,noreferrer");
131
+ };
132
+ const showSearch = apps.length > SEARCH_THRESHOLD;
133
+ const isEmpty = !isLoading && !error && mine.length === 0 && explore.length === 0;
134
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)(react_1.Modal, { children: [variant === "row" ? ((0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "tertiary", "aria-label": label, className: (0, shell_context_js_1.sidebarRowClass)({ className }), children: [(0, jsx_runtime_1.jsx)("span", { className: "flex size-5 shrink-0 items-center justify-center", children: (0, jsx_runtime_1.jsx)(lucide_react_1.LayoutGrid, { className: "size-5" }) }), (0, jsx_runtime_1.jsx)(shell_context_js_1.SidebarLabel, { children: label })] })) : ((0, jsx_runtime_1.jsx)(react_1.Button, { isIconOnly: true, variant: "primary", "aria-label": label, className: className, children: (0, jsx_runtime_1.jsx)(lucide_react_1.LayoutGrid, { className: "size-5" }) })), (0, jsx_runtime_1.jsx)(react_1.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_1.Modal.Dialog, { className: "sm:max-w-lg", children: [(0, jsx_runtime_1.jsx)(react_1.Modal.CloseTrigger, {}), (0, jsx_runtime_1.jsx)(react_1.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Heading, { children: "Your apps" }) }), (0, jsx_runtime_1.jsxs)(react_1.Modal.Body, { className: "space-y-4", children: [showSearch && ((0, jsx_runtime_1.jsxs)(react_1.TextField, { className: "relative", variant: "secondary", value: query, onChange: setQuery, children: [(0, jsx_runtime_1.jsx)(react_1.Label, { className: "sr-only", children: "Filter apps" }), (0, jsx_runtime_1.jsx)(react_1.Input, { className: "pr-9", placeholder: "Filter apps" }), (0, jsx_runtime_1.jsx)(lucide_react_1.Search, { className: "pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted", "aria-hidden": "true" })] })), isLoading && (0, jsx_runtime_1.jsx)(GridSkeleton, {}), !isLoading && error && ((0, jsx_runtime_1.jsx)("p", { className: "py-6 text-center text-sm text-danger", children: "Could not load your apps." })), isEmpty && ((0, jsx_runtime_1.jsx)("p", { className: "py-6 text-center text-sm text-muted", children: query
135
+ ? `Nothing matches “${query}”.`
136
+ : "No apps yet. Anything this workspace can open shows up here." })), !isLoading && !error && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(AppsSection, { title: "Your apps", hint: "Open", apps: mine, offset: 0, onOpen: openApp }), (0, jsx_runtime_1.jsx)(AppsSection, { title: "Explore", hint: "Subscribe on Connect", apps: explore, offset: mine.length, onOpen: openApp })] }))] })] }) }) })] }) }));
48
137
  }
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ /**
3
+ * How a person is rendered when there is no photo to render.
4
+ *
5
+ * Shared rather than copied because two surfaces showing *different* initials
6
+ * for the same account reads as a bug in the account, not in the component -
7
+ * and the One Tap prompt and the user menu are frequently on screen within
8
+ * seconds of each other.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.initials = initials;
12
+ /**
13
+ * Up to two letters standing for a person.
14
+ *
15
+ * Splits on the punctuation an email address uses as well as whitespace, so
16
+ * `priya.nair@acme.test` yields `PN` rather than `PR` - an account with no
17
+ * display name is exactly the case the fallback exists for, and taking the
18
+ * first two characters of a local part would make it useless there.
19
+ */
20
+ function initials(name, email = null) {
21
+ const source = name?.trim() || email?.trim() || "";
22
+ const parts = source.split(/[\s@._-]+/).filter(Boolean);
23
+ return (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
24
+ }
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ /**
3
+ * How long One Tap stays quiet, and why.
4
+ *
5
+ * A prompt that reappears on every page load after being closed is not a
6
+ * convenience, it is an interstitial. Two things silence it, both stored in the
7
+ * application's own `localStorage` - nothing here leaves the browser, and
8
+ * nothing here is a security control:
9
+ *
10
+ * - **Dismissal, escalating.** Closing it once is "not now"; closing it three
11
+ * times is "stop asking". The backoff says that back.
12
+ * - **Signing out.** Someone who has just signed out of this application does
13
+ * not want to be offered the same account back on the next render. That is a
14
+ * different fact from a dismissal, so it does not consume a dismissal, but it
15
+ * buys a day of quiet.
16
+ *
17
+ * Every access is wrapped: `localStorage` throws outright in some embedded and
18
+ * privacy-restricted contexts, and a prompt is never worth breaking a page for.
19
+ * A read that fails reads as "not suppressed", which is the same state a fresh
20
+ * browser is in.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.oneTapSuppressed = oneTapSuppressed;
24
+ exports.dismissOneTap = dismissOneTap;
25
+ exports.suppressOneTapAfterSignOut = suppressOneTapAfterSignOut;
26
+ exports.clearOneTapSuppression = clearOneTapSuppression;
27
+ const STORAGE_KEY = "connect_one_tap";
28
+ /** Escalating quiet after 1, 2, and 3-or-more dismissals. */
29
+ const DISMISS_BACKOFF_MS = [2 * 60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000];
30
+ const SIGN_OUT_QUIET_MS = 24 * 60 * 60 * 1000;
31
+ function read() {
32
+ const empty = { dismissals: 0, until: 0 };
33
+ if (typeof window === "undefined")
34
+ return empty;
35
+ try {
36
+ const raw = window.localStorage.getItem(STORAGE_KEY);
37
+ if (!raw)
38
+ return empty;
39
+ const parsed = JSON.parse(raw);
40
+ return {
41
+ dismissals: typeof parsed.dismissals === "number" ? parsed.dismissals : 0,
42
+ until: typeof parsed.until === "number" ? parsed.until : 0,
43
+ };
44
+ }
45
+ catch {
46
+ return empty;
47
+ }
48
+ }
49
+ function write(state) {
50
+ if (typeof window === "undefined")
51
+ return;
52
+ try {
53
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
54
+ }
55
+ catch {
56
+ // Storage is full, blocked, or unavailable. The prompt simply keeps its
57
+ // default behaviour rather than the page failing.
58
+ }
59
+ }
60
+ /**
61
+ * Whether the prompt may be shown at all.
62
+ *
63
+ * Read once when the component mounts, never during render: `localStorage` is
64
+ * external mutable state, and reading it in a render body is impure - with the
65
+ * React Compiler on, exactly the kind of thing that can be hoisted into the
66
+ * wrong answer. Same reasoning as `ConnectProvider`'s `silentAttemptSpent`.
67
+ */
68
+ function oneTapSuppressed() {
69
+ return Date.now() < read().until;
70
+ }
71
+ /** Records a close, and escalates how long the next quiet period lasts. */
72
+ function dismissOneTap() {
73
+ const { dismissals } = read();
74
+ const next = dismissals + 1;
75
+ const backoff = DISMISS_BACKOFF_MS[Math.min(next, DISMISS_BACKOFF_MS.length) - 1];
76
+ write({ dismissals: next, until: Date.now() + backoff });
77
+ }
78
+ /**
79
+ * Silences the prompt after an explicit sign-out, without spending a dismissal.
80
+ * Signing out is not a complaint about the prompt, so it must not push someone
81
+ * closer to the seven-day backoff.
82
+ */
83
+ function suppressOneTapAfterSignOut() {
84
+ write({ dismissals: read().dismissals, until: Date.now() + SIGN_OUT_QUIET_MS });
85
+ }
86
+ /** Clears the record once a sign-in actually happens - the prompt did its job. */
87
+ function clearOneTapSuppression() {
88
+ if (typeof window === "undefined")
89
+ return;
90
+ try {
91
+ window.localStorage.removeItem(STORAGE_KEY);
92
+ }
93
+ catch {
94
+ // See `write`.
95
+ }
96
+ }
@@ -0,0 +1,106 @@
1
+ "use client";
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.ConnectOneTap = ConnectOneTap;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const react_1 = require("react");
7
+ const lucide_react_1 = require("lucide-react");
8
+ const react_2 = require("@heroui/react");
9
+ const provider_js_1 = require("../provider.js");
10
+ const mark_js_1 = require("../mark.js");
11
+ const identity_js_1 = require("./identity.js");
12
+ const motion_js_1 = require("./motion.js");
13
+ const one_tap_state_js_1 = require("./one-tap-state.js");
14
+ function ConnectOneTap({ onInteractiveSignIn, className = "" }) {
15
+ const { session, isLoading, signIn, readOneTapHint, oneTapSignIn } = (0, provider_js_1.useConnect)();
16
+ const [hint, setHint] = (0, react_1.useState)(null);
17
+ const [phase, setPhase] = (0, react_1.useState)("hidden");
18
+ /**
19
+ * Sampled once at mount rather than read during render, for the same reason
20
+ * `ConnectProvider` samples its silent-attempt marker that way: `localStorage`
21
+ * is external mutable state and reading it in a render body is impure.
22
+ * `false` on the server, which is correct - nothing renders until the hint
23
+ * has resolved on the client anyway.
24
+ */
25
+ const [suppressed] = (0, react_1.useState)(() => typeof window !== "undefined" && (0, one_tap_state_js_1.oneTapSuppressed)());
26
+ const signedOut = !isLoading && !session?.user;
27
+ const askable = signedOut && !suppressed && Boolean(session?.oneTap);
28
+ (0, react_1.useEffect)(() => {
29
+ if (!askable)
30
+ return;
31
+ // Nothing here is a synchronous `setState` in an effect: every write
32
+ // happens after awaiting the network. The flag is what stops a late answer
33
+ // landing on a component that has already unmounted.
34
+ let cancelled = false;
35
+ void readOneTapHint().then((answer) => {
36
+ if (cancelled)
37
+ return;
38
+ // A visitor Connect does not recognise is the common case and gets
39
+ // nothing - no prompt, no flicker, no trace that a question was asked.
40
+ if (!answer || answer.status !== "signed_in")
41
+ return;
42
+ setHint(answer);
43
+ setPhase("offered");
44
+ });
45
+ return () => {
46
+ cancelled = true;
47
+ };
48
+ }, [askable, readOneTapHint]);
49
+ const handoff = (0, react_1.useCallback)(() => {
50
+ setPhase("hidden");
51
+ if (onInteractiveSignIn)
52
+ onInteractiveSignIn();
53
+ else
54
+ signIn();
55
+ }, [onInteractiveSignIn, signIn]);
56
+ const accept = (0, react_1.useCallback)(async () => {
57
+ // A visitor who has never authorised this application cannot be signed in
58
+ // silently and Connect would refuse anyway, so the tap goes straight to the
59
+ // screen that can ask them properly.
60
+ if (!hint?.consented) {
61
+ handoff();
62
+ return;
63
+ }
64
+ setPhase("working");
65
+ const result = await oneTapSignIn();
66
+ if (result.ok) {
67
+ // The provider has already refreshed the session; this component
68
+ // disappears because `signedOut` is now false.
69
+ setPhase("hidden");
70
+ return;
71
+ }
72
+ // `consent_required` and `interaction_required` both mean a screen is
73
+ // genuinely needed. `login_required` means the Connect session went away
74
+ // between the hint and the tap. All three are handed to the interactive
75
+ // flow, which can explain itself; a rate limit or a transport failure just
76
+ // closes the prompt rather than blaming the visitor for tapping.
77
+ if (result.reason === "consent_required" ||
78
+ result.reason === "interaction_required" ||
79
+ result.reason === "login_required") {
80
+ handoff();
81
+ return;
82
+ }
83
+ setPhase("hidden");
84
+ }, [handoff, hint, oneTapSignIn]);
85
+ const dismiss = (0, react_1.useCallback)(() => {
86
+ (0, one_tap_state_js_1.dismissOneTap)();
87
+ setPhase("hidden");
88
+ }, []);
89
+ if (phase === "hidden" || !hint)
90
+ return null;
91
+ const displayName = hint.user?.displayName ?? hint.user?.email ?? null;
92
+ const applicationName = hint.application?.name;
93
+ const busy = phase === "working";
94
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0, y: 12 }, animate: { opacity: 1, y: 0 }, transition: motion_js_1.ENTER, role: "dialog", "aria-label": applicationName ? `Continue to ${applicationName} with CosmoOps` : "Continue with CosmoOps",
95
+ // Fixed, above ordinary content but below a modal: this is an offer,
96
+ // and it must never sit on top of something the visitor opened
97
+ // themselves.
98
+ className: `fixed inset-x-3 bottom-3 z-40 sm:inset-x-auto sm:right-4 sm:bottom-4 sm:w-80 ${className}`, onKeyDown: (event) => {
99
+ if (event.key === "Escape")
100
+ dismiss();
101
+ }, children: (0, jsx_runtime_1.jsxs)(react_2.Card, { className: "p-4 shadow-lg", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-start gap-3", children: [hint.consented ? ((0, jsx_runtime_1.jsxs)(react_2.Avatar, { size: "md", color: "accent", variant: "soft", className: "shrink-0 rounded-full", children: [hint.user?.photoUrl && ((0, jsx_runtime_1.jsx)(react_2.Avatar.Image, { alt: displayName ?? "Your account", src: hint.user.photoUrl })), (0, jsx_runtime_1.jsx)(react_2.Avatar.Fallback, { children: (0, identity_js_1.initials)(hint.user?.displayName ?? null, hint.user?.email ?? null) })] })) : ((0, jsx_runtime_1.jsx)(mark_js_1.ConnectMark, { className: "mt-0.5 size-8 shrink-0" })), (0, jsx_runtime_1.jsxs)("div", { className: "min-w-0 flex-1", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-sm font-medium text-foreground", children: hint.consented ? `Continue as ${displayName ?? "your account"}` : "You're signed in to CosmoOps" }), (0, jsx_runtime_1.jsx)("p", { className: "truncate text-xs text-muted", children: hint.consented
102
+ ? [hint.user?.email, hint.workspace?.name].filter(Boolean).join(" · ")
103
+ : applicationName
104
+ ? `Continue to ${applicationName}`
105
+ : "Continue with your account" })] }), (0, jsx_runtime_1.jsx)(react_2.Button, { variant: "tertiary", size: "sm", isIconOnly: true, "aria-label": "Not now", onPress: dismiss, isDisabled: busy, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] }), (0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "secondary", className: "mt-3 w-full", onPress: () => void accept(), isPending: busy, children: [(0, jsx_runtime_1.jsx)(mark_js_1.ConnectMark, { className: "size-4" }), busy ? "Signing you in…" : "Continue"] })] }) }) }));
106
+ }
@@ -18,6 +18,7 @@
18
18
  */
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.ACTION_LABEL = void 0;
21
+ exports.isFreePlan = isFreePlan;
21
22
  exports.formatPrice = formatPrice;
22
23
  exports.monthlyOf = monthlyOf;
23
24
  exports.intervalLabel = intervalLabel;
@@ -27,6 +28,21 @@ exports.resolvePlanGroup = resolvePlanGroup;
27
28
  exports.groupPlansByTier = groupPlansByTier;
28
29
  exports.annualSaving = annualSaving;
29
30
  exports.resolveAction = resolveAction;
31
+ /**
32
+ * Whether a plan costs nothing - the SDK's copy of Connect's `isFreeSelection`
33
+ * (`packages/data/src/payments/free-plan.ts`), which is what the server
34
+ * actually decides the free-activation path with.
35
+ *
36
+ * A copy rather than an import for the reason `types.ts` gives: the published
37
+ * package carries none of Connect's internals. The rule is one line and does
38
+ * not drift, and `conformance.test.ts` pins the shape either side of it.
39
+ *
40
+ * @param addonsTotal Paise of the add-ons *selected*, not of the plan's whole
41
+ * catalog. A free plan with a paid add-on ticked is not free.
42
+ */
43
+ function isFreePlan(price, addonsTotal = 0) {
44
+ return price === 0 && addonsTotal === 0;
45
+ }
30
46
  /** Paise are the wire unit everywhere in Connect; rupees are the display unit. */
31
47
  function formatPrice(paise) {
32
48
  return new Intl.NumberFormat("en-IN", {