@cosmoops/connect-sdk 0.18.0 → 0.20.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 CHANGED
@@ -3,6 +3,89 @@
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.20.0
7
+
8
+ ### Changed
9
+
10
+ - **The app launcher shows the whole catalogue, in two groups.**
11
+ `<ConnectAppsGrid>` now renders **Your apps** - what this workspace holds a
12
+ live subscription to - and **Explore**, the rest of the platform's active
13
+ applications. A tile in the first opens the application's own site; a tile in
14
+ the second opens that application's page on Connect, where a plan is chosen.
15
+ Your integration builds neither URL.
16
+
17
+ Tiles draw the application's registered icon (its uploaded brand image, or
18
+ its `lucide-react` icon from Connect's curated set) instead of a generic grid
19
+ glyph, the grid animates in, and a filter field appears once there are more
20
+ than eight tiles. The **"Browse all applications"** footer link is **gone**:
21
+ it pointed at `/applications` on Connect, a route that has never existed, so
22
+ the one discovery path the launcher offered was a 404. Discovery belongs in
23
+ the grid.
24
+
25
+ - **`GET /apps` answers with every active application**, not only the
26
+ subscribed ones. `WorkspaceApp` gains two fields:
27
+
28
+ ```ts
29
+ interface WorkspaceApp {
30
+ subscribed: boolean; // launchable now, vs. an invitation to subscribe
31
+ category: WorkspaceAppCategory;
32
+ // …unchanged
33
+ }
34
+ ```
35
+
36
+ `needsAttention` is always `false` when `subscribed` is `false`, and an
37
+ application whose subscription was **cancelled** now appears in the explore
38
+ half rather than disappearing. Applications this workspace's `type` is
39
+ outside the audience of are omitted entirely unless it already holds a
40
+ subscription to one - showing a tile whose only outcome is a refusal at
41
+ checkout is the dead end this change set out to remove.
42
+
43
+ **If you render your own grid from `connect.apps.list()`, filter on
44
+ `subscribed` to get the previous set back.** That is the only migration;
45
+ nothing was removed from the row.
46
+
47
+ ## 0.19.0
48
+
49
+ ### Added
50
+
51
+ - **`connect.contacts`** - who a workspace already knows how to reach.
52
+
53
+ ```ts
54
+ const contact = await connect.contacts.lookupByPhone({ workspaceId, phone });
55
+ if (contact) {
56
+ // A caller this workspace has met before.
57
+ }
58
+ ```
59
+
60
+ `lookupByPhone` is one point read, cheap enough for a screen pop on every
61
+ inbound call, and resolves to `null` for a first-time caller - the ordinary
62
+ case, not an error. `get(contactId, { workspaceId })` reads the record behind
63
+ an id you already hold. Needs a key scoped to `contacts:read` and a live
64
+ subscription in the workspace.
65
+
66
+ **This is the telephony identity, not a CRM record.** A contact is keyed by
67
+ `primaryPhone` - the one fact every inbound call carries - and it is written
68
+ automatically: a voice agent creates one on the first call from a new number
69
+ and fills fields in as it learns them. `source` tells you which kind you are
70
+ holding (`call` means an agent guessed and nobody has confirmed), and
71
+ `phones[].verified` tells you the same about a single value.
72
+
73
+ **Reference it, do not copy it.** If your application keeps its own customer
74
+ records, store this contact's `id` on yours. Two writable copies of a
75
+ person's name with no single owner drift, and Connect's is the one an agent
76
+ keeps updating from calls. Storing the id also makes `Booking.contactId` an
77
+ **exact** join - so "which of my records is this appointment for" is a lookup
78
+ rather than a phone-number match, including for appointments a voice agent
79
+ booked where nobody ever chose one of your records.
80
+
81
+ Read-only, deliberately. Writing into a store with a per-field provenance
82
+ chain means declaring where your facts came from and how they merge with a
83
+ human's; nothing needs that yet.
84
+
85
+ - New exported types: `ConnectContact`, `ContactChannelValue`, `ContactSource`,
86
+ and `ContactsResource` / `LookupContactInput` from
87
+ `@cosmoops/connect-sdk/server`.
88
+
6
89
  ## 0.18.0
7
90
 
8
91
  ### 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.
@@ -342,6 +342,7 @@ try {
342
342
  | `entitlements` | `getReadiness` · `getSubscription` · `hasFeature` |
343
343
  | `billing` | `listPlans` · `getAddonUsage` · `consumeAddon` |
344
344
  | `drive` | `upload` · `getFileUrl` · `getPreviewUrl` |
345
+ | `contacts` | `lookupByPhone` · `get` |
345
346
  | `scheduling` | `listEventTypes` · `findSlots` · `listBookings` · `createBooking` · `rescheduleBooking` · `cancelBooking` |
346
347
  | `ai` | `chat` · `extract` |
347
348
  | `otp` | `send` · `verify` |
@@ -596,7 +597,7 @@ import {
596
597
  | `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`.) |
597
598
  | `ConnectProfileModal` | The same modal with no trigger, driven by `isOpen`/`onOpenChange` — for an app keeping its own account control. |
598
599
  | `ConnectWorkspaceSwitcher` | Sits in the rail's header. Hides itself for an account with one workspace; shows unusable ones disabled, with the reason. |
599
- | `ConnectAppsGrid` | Google-style launcher — grid icon opening a modal of every app this workspace can open. |
600
+ | `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. |
600
601
  | `ConnectPlans` | Monthly/annual toggle, features, add-ons, live discount, coupon field, and the subscribe / upgrade / downgrade / cancel flow. |
601
602
  | `ConnectPlansPage` | A whole `/plans` page — readiness banner, current subscription, and the grid. What `connect-sdk init` scaffolds. |
602
603
  | `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. |
@@ -898,6 +899,18 @@ So the hand-off is a URL, and Connect renders it as a modal over the relevant pa
898
899
 
899
900
  Full history: [CHANGELOG.md](./CHANGELOG.md). Latest first:
900
901
 
902
+ ### 0.20.0
903
+
904
+ **The app launcher shows the whole catalogue.** `<ConnectAppsGrid>` renders two
905
+ groups — **Your apps** (a live subscription, opened at the application's own
906
+ site) and **Explore** (the rest of the platform's active applications, opened
907
+ on Connect). `GET /apps` answers accordingly, and `WorkspaceApp` gains
908
+ `subscribed` and `category`.
909
+
910
+ The "Browse all applications" footer link is gone: it pointed at a Connect
911
+ route that has never existed. **If you render your own grid from
912
+ `connect.apps.list()`, filter on `subscribed` to get the previous set back.**
913
+
901
914
  ### 0.12.0 — breaking
902
915
 
903
916
  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
  }
@@ -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", {
@@ -126,7 +126,7 @@ function ConnectPlans({ planIds, hideCoupon = false, className }) {
126
126
  (tier.monthly?.id === subscription.plan.id || tier.annual?.id === subscription.plan.id);
127
127
  const isCurrent = action === "current";
128
128
  const cadenceUnavailable = showAnnual ? tier.annual === null : tier.monthly === null;
129
- return ((0, jsx_runtime_1.jsx)(motion_js_1.m.div, { variants: { hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0, transition: motion_js_1.ENTER } }, children: (0, jsx_runtime_1.jsxs)(react_1.Card, { className: isCurrentTier ? "h-full border-accent" : "h-full", children: [(0, jsx_runtime_1.jsxs)(react_1.Card.Header, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-start justify-between gap-2", children: [(0, jsx_runtime_1.jsx)(react_1.Card.Title, { children: tier.name }), (0, jsx_runtime_1.jsxs)("div", { className: "flex shrink-0 items-center gap-1.5", children: [tier.annualSavingPercent !== null && showAnnual && ((0, jsx_runtime_1.jsxs)(react_1.Chip, { className: "bg-accent-soft text-accent", children: ["Save ", tier.annualSavingPercent, "%"] })), isCurrentTier && (0, jsx_runtime_1.jsx)(react_1.Chip, { className: "bg-accent-soft text-accent", children: "Current" })] })] }), tier.description && (0, jsx_runtime_1.jsx)(react_1.Card.Description, { children: tier.description })] }), (0, jsx_runtime_1.jsxs)(react_1.Card.Content, { className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(motion_js_1.AnimatePresence, { mode: "popLayout", initial: false, children: (0, jsx_runtime_1.jsxs)(motion_js_1.m.div, { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 }, children: [(0, jsx_runtime_1.jsx)("span", { className: "text-2xl font-semibold text-foreground", children: (0, plan_pricing_js_1.formatPrice)(plan.price) }), (0, jsx_runtime_1.jsx)("span", { className: "text-sm text-muted", children: (0, plan_pricing_js_1.pricePerLabel)(plan.billingInterval) })] }, plan.billingInterval) }), (0, jsx_runtime_1.jsxs)("p", { className: "mt-1 text-xs text-muted", children: ["+", plan.gstRate, "% GST"] }), cadenceUnavailable && ((0, jsx_runtime_1.jsxs)("p", { className: "mt-1 text-xs text-muted", children: ["Only sold ", (0, plan_pricing_js_1.intervalLabel)(plan.billingInterval).adverb, "."] }))] }), plan.trialDays ? ((0, jsx_runtime_1.jsxs)("p", { className: "text-xs font-medium text-accent", children: [plan.trialDays, "-day free trial"] })) : null, plan.features.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: "flex flex-col gap-1.5", children: plan.features.map((feature) => (
129
+ return ((0, jsx_runtime_1.jsx)(motion_js_1.m.div, { variants: { hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0, transition: motion_js_1.ENTER } }, children: (0, jsx_runtime_1.jsxs)(react_1.Card, { className: isCurrentTier ? "h-full border-accent" : "h-full", children: [(0, jsx_runtime_1.jsxs)(react_1.Card.Header, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-start justify-between gap-2", children: [(0, jsx_runtime_1.jsx)(react_1.Card.Title, { children: tier.name }), (0, jsx_runtime_1.jsxs)("div", { className: "flex shrink-0 items-center gap-1.5", children: [tier.annualSavingPercent !== null && showAnnual && ((0, jsx_runtime_1.jsxs)(react_1.Chip, { className: "bg-accent-soft text-accent", children: ["Save ", tier.annualSavingPercent, "%"] })), isCurrentTier && (0, jsx_runtime_1.jsx)(react_1.Chip, { className: "bg-accent-soft text-accent", children: "Current" })] })] }), tier.description && (0, jsx_runtime_1.jsx)(react_1.Card.Description, { children: tier.description })] }), (0, jsx_runtime_1.jsxs)(react_1.Card.Content, { className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(motion_js_1.AnimatePresence, { mode: "popLayout", initial: false, children: (0, jsx_runtime_1.jsx)(motion_js_1.m.div, { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 }, children: (0, plan_pricing_js_1.isFreePlan)(plan.price) ? ((0, jsx_runtime_1.jsx)("span", { className: "text-2xl font-semibold text-foreground", children: "Free" })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "text-2xl font-semibold text-foreground", children: (0, plan_pricing_js_1.formatPrice)(plan.price) }), (0, jsx_runtime_1.jsx)("span", { className: "text-sm text-muted", children: (0, plan_pricing_js_1.pricePerLabel)(plan.billingInterval) })] })) }, plan.billingInterval) }), !(0, plan_pricing_js_1.isFreePlan)(plan.price) && ((0, jsx_runtime_1.jsxs)("p", { className: "mt-1 text-xs text-muted", children: ["+", plan.gstRate, "% GST"] })), cadenceUnavailable && ((0, jsx_runtime_1.jsxs)("p", { className: "mt-1 text-xs text-muted", children: ["Only sold ", (0, plan_pricing_js_1.intervalLabel)(plan.billingInterval).adverb, "."] }))] }), plan.trialDays && !(0, plan_pricing_js_1.isFreePlan)(plan.price) ? ((0, jsx_runtime_1.jsxs)("p", { className: "text-xs font-medium text-accent", children: [plan.trialDays, "-day free trial"] })) : null, plan.features.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: "flex flex-col gap-1.5", children: plan.features.map((feature) => (
130
130
  // `name`, not `key` - the key is an identifier
131
131
  // ("campaign-module"), and printing it here is what this
132
132
  // card used to do. The description rides along as a
@@ -50,7 +50,8 @@ function ConnectSubscribeDialog({ plan, action, current, coupon, onClose, onConf
50
50
  // member and the one most in need of it: the two prices differ by an order
51
51
  // of magnitude, and showing only the new one reads as a price rise.
52
52
  const isChange = action === "upgrade" || action === "downgrade" || isIntervalSwitch;
53
- return ((0, jsx_runtime_1.jsx)(react_1.Modal.Backdrop, { isOpen: plan !== null, onOpenChange: (open) => !open && onClose(), children: (0, jsx_runtime_1.jsx)(react_1.Modal.Container, { size: "sm", children: (0, jsx_runtime_1.jsxs)(react_1.Modal.Dialog, { children: [(0, jsx_runtime_1.jsx)(react_1.Modal.CloseTrigger, { onPress: onClose }), (0, jsx_runtime_1.jsx)(react_1.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Heading, { children: HEADING[action] }) }), plan && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(react_1.Modal.Body, { className: "flex flex-col gap-4", children: [isChange && currentPlan ? ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3 text-sm", children: [(0, jsx_runtime_1.jsxs)("div", { className: "min-w-0", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-muted", children: currentPlan.name }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: [(0, plan_pricing_js_1.formatPrice)(currentPlan.price), "/", (0, plan_pricing_js_1.intervalLabel)(currentPlan.billingInterval).short] })] }), (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowRight, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" }), (0, jsx_runtime_1.jsxs)("div", { className: "min-w-0", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate font-medium text-foreground", children: plan.name }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: [(0, plan_pricing_js_1.formatPrice)(plan.price), "/", (0, plan_pricing_js_1.intervalLabel)(plan.billingInterval).short] })] })] })) : ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "font-medium text-foreground", children: plan.name }), plan.description && (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", children: plan.description })] })), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1 border-t border-separator pt-3 text-sm", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between text-muted", children: [(0, jsx_runtime_1.jsx)("span", { children: plan.name }), (0, jsx_runtime_1.jsx)("span", { children: (0, plan_pricing_js_1.formatPrice)(plan.price) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between text-muted", children: [(0, jsx_runtime_1.jsxs)("span", { children: ["GST (", plan.gstRate, "%)"] }), (0, jsx_runtime_1.jsx)("span", { children: (0, plan_pricing_js_1.formatPrice)(withGst(plan.price, plan.gstRate) - plan.price) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between border-t border-separator pt-1 font-semibold text-foreground", children: [(0, jsx_runtime_1.jsxs)("span", { children: ["Per ", cadenceOf(plan)] }), (0, jsx_runtime_1.jsx)("span", { children: (0, plan_pricing_js_1.formatPrice)(withGst(plan.price, plan.gstRate)) })] }), plan.billingInterval === "annual" && ((0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: ["Works out to ", (0, plan_pricing_js_1.formatPrice)((0, plan_pricing_js_1.monthlyOf)(withGst(plan.price, plan.gstRate), "annual")), " a month, billed yearly."] }))] }), plan.trialDays ? ((0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-accent", children: [plan.trialDays, "-day free trial, if this workspace has not subscribed to this application before."] })) : null, coupon?.trim() ? ((0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: ["Coupon ", (0, jsx_runtime_1.jsx)("span", { className: "font-mono text-foreground", children: coupon.trim() }), " is carried over and validated on Connect."] })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "flex gap-2 rounded-lg bg-surface p-3 text-xs text-muted", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Info, { className: "mt-0.5 size-4 shrink-0", "aria-hidden": "true" }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1", children: [action === "subscribe" && ((0, jsx_runtime_1.jsx)("p", { children: "Payment is completed on Connect, where your card or UPI mandate is set up and the tax invoice is issued. This page updates by itself once it is done." })), action === "upgrade" && ((0, jsx_runtime_1.jsxs)("p", { children: ["Connect prices the difference for the days left in your current cycle and collects just that amount now. From the next cycle you are billed the ", plan.name, " rate."] })), action === "downgrade" && ((0, jsx_runtime_1.jsx)("p", { children: "You keep what you have paid for. Connect works out the unused part of this cycle and applies it as credit or a refund, depending on how this application is configured." })), isIntervalSwitch && (
53
+ return ((0, jsx_runtime_1.jsx)(react_1.Modal.Backdrop, { isOpen: plan !== null, onOpenChange: (open) => !open && onClose(), children: (0, jsx_runtime_1.jsx)(react_1.Modal.Container, { size: "sm", children: (0, jsx_runtime_1.jsxs)(react_1.Modal.Dialog, { children: [(0, jsx_runtime_1.jsx)(react_1.Modal.CloseTrigger, { onPress: onClose }), (0, jsx_runtime_1.jsx)(react_1.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Heading, { children: HEADING[action] }) }), plan && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(react_1.Modal.Body, { className: "flex flex-col gap-4", children: [isChange && currentPlan ? ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3 text-sm", children: [(0, jsx_runtime_1.jsxs)("div", { className: "min-w-0", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-muted", children: currentPlan.name }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: [(0, plan_pricing_js_1.formatPrice)(currentPlan.price), "/", (0, plan_pricing_js_1.intervalLabel)(currentPlan.billingInterval).short] })] }), (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowRight, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" }), (0, jsx_runtime_1.jsxs)("div", { className: "min-w-0", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate font-medium text-foreground", children: plan.name }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: [(0, plan_pricing_js_1.formatPrice)(plan.price), "/", (0, plan_pricing_js_1.intervalLabel)(plan.billingInterval).short] })] })] })) : ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "font-medium text-foreground", children: plan.name }), plan.description && (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", children: plan.description })] })), (0, plan_pricing_js_1.isFreePlan)(plan.price) ? ((0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between border-t border-separator pt-3 text-sm font-semibold text-foreground", children: [(0, jsx_runtime_1.jsx)("span", { children: plan.name }), (0, jsx_runtime_1.jsx)("span", { children: "Free" })] })) : ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1 border-t border-separator pt-3 text-sm", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between text-muted", children: [(0, jsx_runtime_1.jsx)("span", { children: plan.name }), (0, jsx_runtime_1.jsx)("span", { children: (0, plan_pricing_js_1.formatPrice)(plan.price) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between text-muted", children: [(0, jsx_runtime_1.jsxs)("span", { children: ["GST (", plan.gstRate, "%)"] }), (0, jsx_runtime_1.jsx)("span", { children: (0, plan_pricing_js_1.formatPrice)(withGst(plan.price, plan.gstRate) - plan.price) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex justify-between border-t border-separator pt-1 font-semibold text-foreground", children: [(0, jsx_runtime_1.jsxs)("span", { children: ["Per ", cadenceOf(plan)] }), (0, jsx_runtime_1.jsx)("span", { children: (0, plan_pricing_js_1.formatPrice)(withGst(plan.price, plan.gstRate)) })] }), plan.billingInterval === "annual" && ((0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: ["Works out to ", (0, plan_pricing_js_1.formatPrice)((0, plan_pricing_js_1.monthlyOf)(withGst(plan.price, plan.gstRate), "annual")), " a month, billed yearly."] }))] })), plan.trialDays && !(0, plan_pricing_js_1.isFreePlan)(plan.price) ? ((0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-accent", children: [plan.trialDays, "-day free trial, if this workspace has not subscribed to this application before."] })) : null, coupon?.trim() ? ((0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: ["Coupon ", (0, jsx_runtime_1.jsx)("span", { className: "font-mono text-foreground", children: coupon.trim() }), " is carried over and validated on Connect."] })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "flex gap-2 rounded-lg bg-surface p-3 text-xs text-muted", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Info, { className: "mt-0.5 size-4 shrink-0", "aria-hidden": "true" }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1", children: [action === "subscribe" &&
54
+ ((0, plan_pricing_js_1.isFreePlan)(plan.price) ? ((0, jsx_runtime_1.jsx)("p", { children: "This plan is free. Connect activates it straight away - there is no payment to make, no card or UPI mandate to set up, and no invoice to expect. This page updates by itself once it is done." })) : ((0, jsx_runtime_1.jsx)("p", { children: "Payment is completed on Connect, where your card or UPI mandate is set up and the tax invoice is issued. This page updates by itself once it is done." }))), action === "upgrade" && ((0, jsx_runtime_1.jsxs)("p", { children: ["Connect prices the difference for the days left in your current cycle and collects just that amount now. From the next cycle you are billed the ", plan.name, " rate."] })), action === "downgrade" && ((0, jsx_runtime_1.jsx)("p", { children: "You keep what you have paid for. Connect works out the unused part of this cycle and applies it as credit or a refund, depending on how this application is configured." })), isIntervalSwitch && (
54
55
  // Not a proration. The cycle you are on ends, so this is
55
56
  // the one action where the amount collected can exceed a
56
57
  // single cycle's list price - or be nothing at all, with
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.normalizePath = exports.flattenNav = exports.normalizeNav = exports.useActivePath = exports.ConnectSideNav = exports.ConnectSidebarNav = exports.RAIL_EXPANDED = exports.RAIL_COLLAPSED = exports.sidebarRowClass = exports.useConnectShell = exports.ConnectShellProvider = exports.ConnectSidebarRow = exports.ConnectSidebarFooter = exports.ConnectSidebarBody = exports.ConnectSidebarHeader = exports.ConnectSidebar = exports.AppShell = exports.ConnectAccountSessions = exports.ConnectSignOutButton = exports.ConnectHomeButton = exports.ConnectProfileButton = exports.ConnectInvoicesButton = exports.ConnectManageSubscriptionButton = exports.ConnectUserMenu = exports.ConnectProfileModal = exports.ConnectUserButton = exports.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.useConnect = exports.ConnectProvider = void 0;
5
5
  exports.annualSaving = exports.resolveAction = exports.monthlyOf = exports.formatPrice = exports.buildCheckoutUrl = exports.useConnectCheckout = exports.ConnectFilterTabs = exports.ConnectCursorPagination = exports.ConnectConfirmDialog = exports.ConnectLinkButton = exports.ConnectPlansPage = exports.ConnectUsageMeter = exports.ConnectPlatformGate = exports.ConnectWorkspaceTypeGate = exports.useConnectFeature = exports.ConnectFeatureGate = exports.useConnectAccessGate = exports.ConnectAccessGate = exports.ConnectReadinessGate = exports.ConnectReadinessBanner = exports.resolveSubscriptionState = exports.ConnectSeatUsage = exports.ConnectTrialBanner = exports.ConnectSubscriptionCard = exports.ConnectNoSubscriptionCard = exports.ConnectSubscriptionGate = exports.ConnectSubscribeDialog = exports.ConnectPlans = exports.ConnectIntegrationsModal = exports.AI_TONE_DOT = exports.AI_TONE_CHIP = exports.AI_EMPTY_VALUE = exports.aiStatusTone = exports.isNumericAiFormat = exports.formatAiValue = exports.AiSources = exports.AiSteps = exports.AiDetails = exports.AiTimeline = exports.AiMetrics = exports.AiChart = exports.AiTable = exports.ConnectAskAi = exports.ConnectAppsGrid = exports.isLinkableConnectScreen = exports.matchConnectScreen = exports.filterNavForAudience = exports.gateExemptPaths = exports.isNavItemActive = exports.resolveActivePath = void 0;
6
- exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useBookableEventTypes = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useConnectWorkspaceMembers = exports.useConnectWorkspaces = exports.ConnectWorkspaceSwitcher = exports.resolvePlanGroup = exports.groupPlansByTier = exports.ACTION_LABEL = exports.discountLabel = void 0;
6
+ exports.zonedInstant = exports.dayColumns = exports.layoutBookings = exports.useBookingActions = exports.useBookings = exports.useAvailableSlots = exports.useBookableEventTypes = exports.ConnectBookingDetail = exports.ConnectBookingDialog = exports.ConnectBookingCalendar = exports.useConnectWorkspaceMembers = exports.useConnectWorkspaces = exports.ConnectWorkspaceSwitcher = exports.resolvePlanGroup = exports.groupPlansByTier = exports.isFreePlan = exports.ACTION_LABEL = exports.discountLabel = void 0;
7
7
  /**
8
8
  * Browser half of `@cosmoops/connect-sdk` - React components and hooks.
9
9
  *
@@ -211,6 +211,12 @@ Object.defineProperty(exports, "resolveAction", { enumerable: true, get: functio
211
211
  Object.defineProperty(exports, "annualSaving", { enumerable: true, get: function () { return plan_pricing_js_1.annualSaving; } });
212
212
  Object.defineProperty(exports, "discountLabel", { enumerable: true, get: function () { return plan_pricing_js_1.discountLabel; } });
213
213
  Object.defineProperty(exports, "ACTION_LABEL", { enumerable: true, get: function () { return plan_pricing_js_1.ACTION_LABEL; } });
214
+ // Exported for the same reason `groupPlansByTier` is: a partner writing its
215
+ // own pricing grid has to draw a ₹0 plan as "Free" rather than as a price
216
+ // with a cadence and a GST rate attached, and re-deriving "is this free"
217
+ // from a raw `price === 0` misses the add-on half of the rule Connect
218
+ // actually applies.
219
+ Object.defineProperty(exports, "isFreePlan", { enumerable: true, get: function () { return plan_pricing_js_1.isFreePlan; } });
214
220
  // The tier model, exported because a partner writing its own pricing grid
215
221
  // needs the same pairing `ConnectPlans` uses. Grouping a flat plan list by
216
222
  // hand means matching on `name`, which Connect neither enforces unique nor
@@ -7,6 +7,7 @@ const ai_js_1 = require("./resources/ai.js");
7
7
  const data_js_1 = require("./resources/data.js");
8
8
  const workspaces_js_1 = require("./resources/workspaces.js");
9
9
  const scheduling_js_1 = require("./resources/scheduling.js");
10
+ const contacts_js_1 = require("./resources/contacts.js");
10
11
  const kv_js_1 = require("./resources/kv.js");
11
12
  const billing_js_1 = require("./resources/billing.js");
12
13
  const drive_js_1 = require("./resources/drive.js");
@@ -82,6 +83,8 @@ class ConnectClient {
82
83
  drive;
83
84
  /** A subscribing workspace's own calendar - what is bookable, free, and booked. */
84
85
  scheduling;
86
+ /** Who a workspace already knows how to reach, by phone or by id. */
87
+ contacts;
85
88
  /** RAG chat and structured extraction. */
86
89
  ai;
87
90
  /** Your application's own document store - see `DataResource`. */
@@ -131,6 +134,7 @@ class ConnectClient {
131
134
  this.billing = new billing_js_1.BillingResource(transport);
132
135
  this.drive = new drive_js_1.DriveResource(transport);
133
136
  this.scheduling = new scheduling_js_1.SchedulingResource(transport);
137
+ this.contacts = new contacts_js_1.ContactsResource(transport);
134
138
  this.ai = new ai_js_1.AiResource(transport);
135
139
  this.data = new data_js_1.DataResource(transport);
136
140
  this.kv = new kv_js_1.KvResource(transport);
@@ -146,4 +150,4 @@ class ConnectClient {
146
150
  }
147
151
  exports.ConnectClient = ConnectClient;
148
152
  /** Kept in step with `package.json` by `sdk.test.ts`. */
149
- exports.SDK_VERSION = "0.18.0";
153
+ exports.SDK_VERSION = "0.20.0";
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ContactsResource = void 0;
4
+ const http_js_1 = require("../http.js");
5
+ const acting_as_js_1 = require("./acting-as.js");
6
+ /**
7
+ * Who a workspace already knows how to reach.
8
+ *
9
+ * **This is the telephony identity, not a CRM record.** A contact is keyed by
10
+ * `primaryPhone` - the one fact every inbound call carries - and it is written
11
+ * automatically: a voice agent creates one on the first call from a new number
12
+ * and fills fields in as it learns them. `source` says which kind you are
13
+ * holding, and it is worth rendering: `call` means an agent guessed and nobody
14
+ * has confirmed, `manual` means a person typed it.
15
+ *
16
+ * **Reference it, do not copy it.** If your application keeps its own customer
17
+ * records, store this contact's `id` on yours rather than duplicating the
18
+ * fields. Two writable copies of a person's name with no single owner drift,
19
+ * and Connect's is the one an agent keeps updating from calls. The id is also
20
+ * an exact join to anything else keyed by it - `Booking.contactId`, most
21
+ * usefully - so "which of my records is this appointment for" becomes a lookup
22
+ * instead of a phone match.
23
+ *
24
+ * Read-only for now. Writing into a store with a per-field provenance chain
25
+ * means declaring where your facts came from and how they merge with a
26
+ * human's, and nothing needs that yet.
27
+ */
28
+ class ContactsResource {
29
+ transport;
30
+ constructor(transport) {
31
+ this.transport = transport;
32
+ }
33
+ /**
34
+ * "Do we already know this number?" - one point read, cheap enough for a
35
+ * screen pop on every inbound call.
36
+ *
37
+ * Resolves to `null` for a number nobody has called from before. That is the
38
+ * ordinary case for a first-time caller, not an error, which is why it is a
39
+ * null rather than a thrown `ConnectError`.
40
+ */
41
+ async lookupByPhone(input) {
42
+ const result = await (0, http_js_1.request)(this.transport, {
43
+ method: "GET",
44
+ path: "/contacts",
45
+ query: { workspaceId: input.workspaceId, phone: input.phone },
46
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
47
+ });
48
+ return result.contact ?? null;
49
+ }
50
+ /**
51
+ * The record behind an id you already hold.
52
+ *
53
+ * Throws `ConnectError` with `code: "not_found"` for an id that does not
54
+ * exist *or* belongs to another workspace - deliberately the same answer,
55
+ * since telling those apart would confirm a contact exists somewhere else.
56
+ */
57
+ get(contactId, options) {
58
+ return (0, http_js_1.request)(this.transport, {
59
+ method: "GET",
60
+ path: `/contacts/${encodeURIComponent(contactId)}`,
61
+ query: { workspaceId: options.workspaceId },
62
+ headers: (0, acting_as_js_1.actingAs)(options.asUser),
63
+ });
64
+ }
65
+ }
66
+ exports.ContactsResource = ContactsResource;
@@ -1,45 +1,134 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { Button, Modal, Skeleton } from "@heroui/react";
4
- import { AlertCircle, ExternalLink, LayoutGrid } from "lucide-react";
3
+ import { Button, Input, Label, Modal, Skeleton, TextField } from "@heroui/react";
4
+ import { AlertCircle, ArrowUpRight, BarChart3, Calendar, Cloud, Cpu, CreditCard, Database, HardDrive, LayoutGrid, LineChart, ListChecks, Lock, Mail, MessageSquare, Package, Plus, Receipt, Search, Server, ShieldCheck, } from "lucide-react";
5
+ import { useMemo, useState } from "react";
5
6
  import { useConnectApps, useConnect } from "../provider.js";
6
- import { ENTER, m, MotionProvider } from "./motion.js";
7
+ import { m, MotionProvider } from "./motion.js";
7
8
  import { SidebarLabel, sidebarRowClass } from "./shell-context.js";
8
- function AppTile({ app }) {
9
- const disabled = app.launchUrl === null;
10
- const body = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "relative flex size-12 items-center justify-center overflow-hidden rounded-xl bg-surface-secondary", children: [app.iconUrl ? (
11
- // Deliberately a plain `<img>`: this package ships into any React app
12
- // and cannot assume `next/image` or any other framework loader.
13
- // eslint-disable-next-line @next/next/no-img-element
14
- _jsx("img", { src: app.iconUrl, alt: "", className: "size-full object-cover" })) : (_jsx(LayoutGrid, { className: "size-6 text-muted", "aria-hidden": "true" })), app.needsAttention && (_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: _jsx(AlertCircle, { className: "size-3", "aria-hidden": "true" }) }))] }), _jsx("span", { className: "line-clamp-2 text-center text-xs font-medium text-foreground", children: app.name }), app.isCurrent && _jsx("span", { className: "text-[0.625rem] uppercase tracking-wide text-accent", children: "Current" })] }));
15
- const shared = "flex flex-col items-center gap-2 rounded-xl p-3 transition-colors focus-visible:outline-2 focus-visible:outline-focus";
9
+ /**
10
+ * Connect's curated `Application.icon` set, as components.
11
+ *
12
+ * A closed map rather than a dynamic lookup on `lucide-react`: the icon name
13
+ * arrives over the wire, and resolving an arbitrary string against a module's
14
+ * exports is how a catalog typo becomes a render crash. Anything unrecognised
15
+ * - including the older, lowercased names some applications were registered
16
+ * with - falls through to the neutral tile glyph.
17
+ */
18
+ const APP_ICONS = {
19
+ Mail,
20
+ MessageSquare,
21
+ Database,
22
+ HardDrive,
23
+ BarChart3,
24
+ LineChart,
25
+ ShieldCheck,
26
+ Lock,
27
+ CreditCard,
28
+ Receipt,
29
+ ListChecks,
30
+ Calendar,
31
+ Server,
32
+ Cloud,
33
+ Cpu,
34
+ Package,
35
+ };
36
+ /** Below this many tiles a filter field is one more thing to look past. */
37
+ const SEARCH_THRESHOLD = 8;
38
+ /**
39
+ * The tile's own glyph: the uploaded brand icon when there is one, the
40
+ * curated lucide icon when there is not, and the neutral grid mark when the
41
+ * name is one this version of the SDK does not know.
42
+ */
43
+ function AppGlyph({ app }) {
44
+ if (app.iconUrl) {
45
+ // Deliberately a plain `<img>`: this package ships into any React app
46
+ // and cannot assume `next/image` or any other framework loader.
47
+ // eslint-disable-next-line @next/next/no-img-element
48
+ return _jsx("img", { src: app.iconUrl, alt: "", className: "size-full object-cover" });
49
+ }
50
+ const Icon = APP_ICONS[app.icon] ?? LayoutGrid;
51
+ return _jsx(Icon, { className: "size-6", "aria-hidden": "true" });
52
+ }
53
+ function AppTile({ app, onOpen }) {
54
+ // A subscribed app with no registered site is the one genuinely dead tile:
55
+ // there is nothing to open and nothing to buy.
56
+ const disabled = app.subscribed && app.launchUrl === null;
57
+ 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";
58
+ const body = (_jsxs(_Fragment, { children: [_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: [_jsx(AppGlyph, { app: app }), app.needsAttention && (_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: _jsx(AlertCircle, { className: "size-3", "aria-hidden": "true" }) })), !app.subscribed && (_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: _jsx(Plus, { className: "size-3", "aria-hidden": "true" }) }))] }), _jsx("span", { className: "line-clamp-2 text-xs font-medium leading-tight text-foreground", children: app.name }), app.isCurrent ? (_jsx("span", { className: "text-[0.625rem] uppercase tracking-wide text-accent", children: "Current" })) : (!app.subscribed && (_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", _jsx(ArrowUpRight, { className: "size-2.5", "aria-hidden": "true" })] })))] }));
16
59
  if (disabled) {
17
60
  return (_jsx("div", { className: `${shared} cursor-default opacity-60`, title: `${app.name} has no launch URL configured`, children: body }));
18
61
  }
19
- return (_jsx("a", { href: app.launchUrl, target: "_blank", rel: "noopener noreferrer", className: `${shared} hover:bg-surface-secondary`, title: app.description || app.name, children: body }));
62
+ return (
63
+ // A `<button>` rather than an `<a>`: where the tile goes depends on
64
+ // `subscribed` *and* on Connect's own origin, which only the provider
65
+ // knows, so one handler resolves it rather than every caller rebuilding
66
+ // the URL. Both destinations still open in a new tab - a launcher must
67
+ // not throw away the page you launched it from.
68
+ _jsx(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 }));
69
+ }
70
+ function AppsSection({ title, hint, apps, offset, onOpen, }) {
71
+ if (apps.length === 0)
72
+ return null;
73
+ return (_jsxs("section", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 px-1", children: [_jsx("h3", { className: "text-[0.6875rem] font-semibold uppercase tracking-[0.06em] text-muted", children: title }), _jsx("p", { className: "text-[0.6875rem] text-muted", children: hint })] }), _jsx("div", { className: "grid grid-cols-3 gap-1 sm:grid-cols-4", children: apps.map((app, index) => (_jsx(m.div, { initial: { opacity: 0, scale: 0.9, y: 8 }, animate: { opacity: 1, scale: 1, y: 0 }, transition: {
74
+ type: "spring",
75
+ stiffness: 420,
76
+ damping: 30,
77
+ // Capped so a large catalog does not make the last tile wait.
78
+ delay: Math.min(offset + index, 14) * 0.018,
79
+ }, children: _jsx(AppTile, { app: app, onOpen: onOpen }) }, app.applicationId))) })] }));
80
+ }
81
+ function GridSkeleton() {
82
+ return (_jsx("div", { className: "grid grid-cols-3 gap-1 sm:grid-cols-4", "aria-hidden": "true", children: Array.from({ length: 8 }, (_, index) => (_jsxs("div", { className: "flex flex-col items-center gap-2 p-3", children: [_jsx(Skeleton, { className: "size-14 rounded-[0.8125rem]" }), _jsx(Skeleton, { className: "h-3 w-14" })] }, index))) }));
20
83
  }
21
84
  /**
22
- * The Google-style app launcher: a grid icon in your top bar that opens a
23
- * modal of every Connect application this workspace can actually open.
85
+ * The Google-style app launcher: a grid icon that opens every Connect
86
+ * application this workspace has, and every one it could have.
87
+ *
88
+ * Two sections, because they are two different actions. **Your apps** is what
89
+ * the workspace can open right now - the current application pinned first and
90
+ * marked, so the grid never has a hole where "you are here" should be, and a
91
+ * paused subscription still listed but badged, because a dunning cycle should
92
+ * not make a product look deleted. **Explore** is the rest of the active
93
+ * catalog, minus anything this workspace's type could never subscribe to, and
94
+ * a tile there opens that application's page on Connect.
24
95
  *
25
- * Only launchable apps appear, so no tile is a dead end - a paused
26
- * subscription still shows, badged, because a dunning cycle should not make a
27
- * product look deleted. The current app is pinned first and marked, rather
28
- * than filtered out, so the grid never has a hole where "you are here" should
29
- * be.
96
+ * It used to show subscriptions only, with a "Browse all applications" link
97
+ * under the grid pointing at a Connect route that does not exist - so a
98
+ * workspace with one subscription saw one tile and had no working way to find
99
+ * anything else. Discovery belongs in the grid.
30
100
  *
31
101
  * Renders nothing when signed out: a launcher with nothing to launch is just a
32
102
  * button that opens an empty box.
33
103
  */
34
104
  export function ConnectAppsGrid({ className, label = "Switch app", variant = "row" }) {
35
- const { session, isLoading: sessionLoading } = useConnect();
105
+ const { session, isLoading: sessionLoading, appUrl } = useConnect();
36
106
  const { data, isLoading, error } = useConnectApps();
107
+ const [query, setQuery] = useState("");
108
+ const apps = useMemo(() => data?.apps ?? [], [data]);
109
+ const { mine, explore } = useMemo(() => {
110
+ const needle = query.trim().toLowerCase();
111
+ const matches = needle
112
+ ? apps.filter((app) => app.name.toLowerCase().includes(needle) || app.description.toLowerCase().includes(needle))
113
+ : apps;
114
+ return {
115
+ mine: matches.filter((app) => app.subscribed),
116
+ explore: matches.filter((app) => !app.subscribed),
117
+ };
118
+ }, [apps, query]);
37
119
  if (sessionLoading || !session?.user)
38
120
  return null;
39
- const apps = data?.apps ?? [];
40
- return (_jsx(MotionProvider, { children: _jsxs(Modal, { children: [variant === "row" ? (_jsxs(Button, { variant: "tertiary", "aria-label": label, className: sidebarRowClass({ className }), children: [_jsx("span", { className: "flex size-5 shrink-0 items-center justify-center", children: _jsx(LayoutGrid, { className: "size-5" }) }), _jsx(SidebarLabel, { children: label })] })) : (_jsx(Button, { isIconOnly: true, variant: "primary", "aria-label": label, className: className, children: _jsx(LayoutGrid, { className: "size-5" }) })), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-md", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Your apps" }) }), _jsxs(Modal.Body, { children: [isLoading && (_jsx("div", { className: "grid grid-cols-3 gap-2", children: Array.from({ length: 6 }, (_, index) => (_jsxs("div", { className: "flex flex-col items-center gap-2 p-3", children: [_jsx(Skeleton, { className: "size-12 rounded-xl" }), _jsx(Skeleton, { className: "h-3 w-14" })] }, index))) })), !isLoading && error && (_jsx("p", { className: "py-6 text-center text-sm text-danger", children: "Could not load your apps." })), !isLoading && !error && apps.length === 0 && (_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 && (_jsx(m.div, { className: "grid grid-cols-3 gap-2", variants: { show: { transition: { staggerChildren: 0.04 } } }, initial: "hidden", animate: "show", children: apps.map((app) => (_jsx(m.div, { variants: { hidden: { opacity: 0, y: 8 }, show: { opacity: 1, y: 0, transition: ENTER } }, children: _jsx(AppTile, { app: app }) }, app.applicationId))) }))] }), _jsx(Modal.Footer, { children: _jsx(ConnectBrowseAllLink, {}) })] }) }) })] }) }));
41
- }
42
- function ConnectBrowseAllLink() {
43
- const { appUrl } = useConnect();
44
- return (_jsxs(Button, { variant: "tertiary", className: "w-full", onPress: () => window.open(`${appUrl}/applications`, "_blank", "noopener,noreferrer"), children: ["Browse all applications", _jsx(ExternalLink, { className: "size-3.5 text-muted" })] }));
121
+ const openApp = (app) => {
122
+ // Subscribed goes to the partner's own site; everything else goes to that
123
+ // application's page on Connect, which is where a plan is actually
124
+ // chosen. The SDK owns this mapping so no integration has to know either
125
+ // URL shape.
126
+ const target = app.subscribed && app.launchUrl ? app.launchUrl : `${appUrl}/apps/${app.applicationId}`;
127
+ window.open(target, "_blank", "noopener,noreferrer");
128
+ };
129
+ const showSearch = apps.length > SEARCH_THRESHOLD;
130
+ const isEmpty = !isLoading && !error && mine.length === 0 && explore.length === 0;
131
+ return (_jsx(MotionProvider, { children: _jsxs(Modal, { children: [variant === "row" ? (_jsxs(Button, { variant: "tertiary", "aria-label": label, className: sidebarRowClass({ className }), children: [_jsx("span", { className: "flex size-5 shrink-0 items-center justify-center", children: _jsx(LayoutGrid, { className: "size-5" }) }), _jsx(SidebarLabel, { children: label })] })) : (_jsx(Button, { isIconOnly: true, variant: "primary", "aria-label": label, className: className, children: _jsx(LayoutGrid, { className: "size-5" }) })), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-lg", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Your apps" }) }), _jsxs(Modal.Body, { className: "space-y-4", children: [showSearch && (_jsxs(TextField, { className: "relative", variant: "secondary", value: query, onChange: setQuery, children: [_jsx(Label, { className: "sr-only", children: "Filter apps" }), _jsx(Input, { className: "pr-9", placeholder: "Filter apps" }), _jsx(Search, { className: "pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted", "aria-hidden": "true" })] })), isLoading && _jsx(GridSkeleton, {}), !isLoading && error && (_jsx("p", { className: "py-6 text-center text-sm text-danger", children: "Could not load your apps." })), isEmpty && (_jsx("p", { className: "py-6 text-center text-sm text-muted", children: query
132
+ ? `Nothing matches “${query}”.`
133
+ : "No apps yet. Anything this workspace can open shows up here." })), !isLoading && !error && (_jsxs(_Fragment, { children: [_jsx(AppsSection, { title: "Your apps", hint: "Open", apps: mine, offset: 0, onOpen: openApp }), _jsx(AppsSection, { title: "Explore", hint: "Subscribe on Connect", apps: explore, offset: mine.length, onOpen: openApp })] }))] })] }) }) })] }) }));
45
134
  }
@@ -15,6 +15,21 @@
15
15
  * No React here - it is imported by both a client component and its dialog,
16
16
  * and nothing in it needs to be.
17
17
  */
18
+ /**
19
+ * Whether a plan costs nothing - the SDK's copy of Connect's `isFreeSelection`
20
+ * (`packages/data/src/payments/free-plan.ts`), which is what the server
21
+ * actually decides the free-activation path with.
22
+ *
23
+ * A copy rather than an import for the reason `types.ts` gives: the published
24
+ * package carries none of Connect's internals. The rule is one line and does
25
+ * not drift, and `conformance.test.ts` pins the shape either side of it.
26
+ *
27
+ * @param addonsTotal Paise of the add-ons *selected*, not of the plan's whole
28
+ * catalog. A free plan with a paid add-on ticked is not free.
29
+ */
30
+ export function isFreePlan(price, addonsTotal = 0) {
31
+ return price === 0 && addonsTotal === 0;
32
+ }
18
33
  /** Paise are the wire unit everywhere in Connect; rupees are the display unit. */
19
34
  export function formatPrice(paise) {
20
35
  return new Intl.NumberFormat("en-IN", {
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { Button, Card, Chip, Input, Skeleton, Switch } from "@heroui/react";
4
4
  import { Check, CheckCircle2, ExternalLink, Loader2, Sparkles, Tag } from "lucide-react";
5
5
  import { useMemo, useState } from "react";
@@ -7,7 +7,7 @@ import { useConnect, useConnectEntitlements, useConnectPlans } from "../provider
7
7
  import { buildCancelUrl, buildCheckoutUrl, useConnectCheckout, } from "../use-checkout.js";
8
8
  import { AnimatePresence, ENTER, m, MotionProvider } from "./motion.js";
9
9
  import { ConnectSubscribeDialog } from "./subscribe-dialog.js";
10
- import { ACTION_LABEL, discountLabel, formatPrice, groupPlansByTier, intervalLabel, pricePerLabel, resolveAction, } from "./plan-pricing.js";
10
+ import { ACTION_LABEL, discountLabel, formatPrice, groupPlansByTier, intervalLabel, isFreePlan, pricePerLabel, resolveAction, } from "./plan-pricing.js";
11
11
  /**
12
12
  * The plan table: monthly/annual toggle, features, add-ons, the discount
13
13
  * actually in effect, and the subscribe / upgrade / downgrade flows.
@@ -123,7 +123,7 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
123
123
  (tier.monthly?.id === subscription.plan.id || tier.annual?.id === subscription.plan.id);
124
124
  const isCurrent = action === "current";
125
125
  const cadenceUnavailable = showAnnual ? tier.annual === null : tier.monthly === null;
126
- return (_jsx(m.div, { variants: { hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0, transition: ENTER } }, children: _jsxs(Card, { className: isCurrentTier ? "h-full border-accent" : "h-full", children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: tier.name }), _jsxs("div", { className: "flex shrink-0 items-center gap-1.5", children: [tier.annualSavingPercent !== null && showAnnual && (_jsxs(Chip, { className: "bg-accent-soft text-accent", children: ["Save ", tier.annualSavingPercent, "%"] })), isCurrentTier && _jsx(Chip, { className: "bg-accent-soft text-accent", children: "Current" })] })] }), tier.description && _jsx(Card.Description, { children: tier.description })] }), _jsxs(Card.Content, { className: "flex flex-col gap-4", children: [_jsxs("div", { children: [_jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsxs(m.div, { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 }, children: [_jsx("span", { className: "text-2xl font-semibold text-foreground", children: formatPrice(plan.price) }), _jsx("span", { className: "text-sm text-muted", children: pricePerLabel(plan.billingInterval) })] }, plan.billingInterval) }), _jsxs("p", { className: "mt-1 text-xs text-muted", children: ["+", plan.gstRate, "% GST"] }), cadenceUnavailable && (_jsxs("p", { className: "mt-1 text-xs text-muted", children: ["Only sold ", intervalLabel(plan.billingInterval).adverb, "."] }))] }), plan.trialDays ? (_jsxs("p", { className: "text-xs font-medium text-accent", children: [plan.trialDays, "-day free trial"] })) : null, plan.features.length > 0 && (_jsx("ul", { className: "flex flex-col gap-1.5", children: plan.features.map((feature) => (
126
+ return (_jsx(m.div, { variants: { hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0, transition: ENTER } }, children: _jsxs(Card, { className: isCurrentTier ? "h-full border-accent" : "h-full", children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: tier.name }), _jsxs("div", { className: "flex shrink-0 items-center gap-1.5", children: [tier.annualSavingPercent !== null && showAnnual && (_jsxs(Chip, { className: "bg-accent-soft text-accent", children: ["Save ", tier.annualSavingPercent, "%"] })), isCurrentTier && _jsx(Chip, { className: "bg-accent-soft text-accent", children: "Current" })] })] }), tier.description && _jsx(Card.Description, { children: tier.description })] }), _jsxs(Card.Content, { className: "flex flex-col gap-4", children: [_jsxs("div", { children: [_jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(m.div, { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 }, children: isFreePlan(plan.price) ? (_jsx("span", { className: "text-2xl font-semibold text-foreground", children: "Free" })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-2xl font-semibold text-foreground", children: formatPrice(plan.price) }), _jsx("span", { className: "text-sm text-muted", children: pricePerLabel(plan.billingInterval) })] })) }, plan.billingInterval) }), !isFreePlan(plan.price) && (_jsxs("p", { className: "mt-1 text-xs text-muted", children: ["+", plan.gstRate, "% GST"] })), cadenceUnavailable && (_jsxs("p", { className: "mt-1 text-xs text-muted", children: ["Only sold ", intervalLabel(plan.billingInterval).adverb, "."] }))] }), plan.trialDays && !isFreePlan(plan.price) ? (_jsxs("p", { className: "text-xs font-medium text-accent", children: [plan.trialDays, "-day free trial"] })) : null, plan.features.length > 0 && (_jsx("ul", { className: "flex flex-col gap-1.5", children: plan.features.map((feature) => (
127
127
  // `name`, not `key` - the key is an identifier
128
128
  // ("campaign-module"), and printing it here is what this
129
129
  // card used to do. The description rides along as a
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { Button, Modal } from "@heroui/react";
4
4
  import { ArrowRight, ExternalLink, Info } from "lucide-react";
5
- import { formatPrice, intervalLabel, monthlyOf } from "./plan-pricing.js";
5
+ import { formatPrice, intervalLabel, isFreePlan, monthlyOf } from "./plan-pricing.js";
6
6
  /** GST is quoted exclusive everywhere in Connect; this is the same arithmetic `calculateGST` does server-side. */
7
7
  function withGst(price, gstRate) {
8
8
  return price + Math.round(price * (gstRate / 100));
@@ -47,7 +47,8 @@ export function ConnectSubscribeDialog({ plan, action, current, coupon, onClose,
47
47
  // member and the one most in need of it: the two prices differ by an order
48
48
  // of magnitude, and showing only the new one reads as a price rise.
49
49
  const isChange = action === "upgrade" || action === "downgrade" || isIntervalSwitch;
50
- return (_jsx(Modal.Backdrop, { isOpen: plan !== null, onOpenChange: (open) => !open && onClose(), children: _jsx(Modal.Container, { size: "sm", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, { onPress: onClose }), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: HEADING[action] }) }), plan && (_jsxs(_Fragment, { children: [_jsxs(Modal.Body, { className: "flex flex-col gap-4", children: [isChange && currentPlan ? (_jsxs("div", { className: "flex items-center gap-3 text-sm", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-muted", children: currentPlan.name }), _jsxs("p", { className: "text-xs text-muted", children: [formatPrice(currentPlan.price), "/", intervalLabel(currentPlan.billingInterval).short] })] }), _jsx(ArrowRight, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate font-medium text-foreground", children: plan.name }), _jsxs("p", { className: "text-xs text-muted", children: [formatPrice(plan.price), "/", intervalLabel(plan.billingInterval).short] })] })] })) : (_jsxs("div", { children: [_jsx("p", { className: "font-medium text-foreground", children: plan.name }), plan.description && _jsx("p", { className: "text-xs text-muted", children: plan.description })] })), _jsxs("div", { className: "flex flex-col gap-1 border-t border-separator pt-3 text-sm", children: [_jsxs("div", { className: "flex justify-between text-muted", children: [_jsx("span", { children: plan.name }), _jsx("span", { children: formatPrice(plan.price) })] }), _jsxs("div", { className: "flex justify-between text-muted", children: [_jsxs("span", { children: ["GST (", plan.gstRate, "%)"] }), _jsx("span", { children: formatPrice(withGst(plan.price, plan.gstRate) - plan.price) })] }), _jsxs("div", { className: "flex justify-between border-t border-separator pt-1 font-semibold text-foreground", children: [_jsxs("span", { children: ["Per ", cadenceOf(plan)] }), _jsx("span", { children: formatPrice(withGst(plan.price, plan.gstRate)) })] }), plan.billingInterval === "annual" && (_jsxs("p", { className: "text-xs text-muted", children: ["Works out to ", formatPrice(monthlyOf(withGst(plan.price, plan.gstRate), "annual")), " a month, billed yearly."] }))] }), plan.trialDays ? (_jsxs("p", { className: "text-xs text-accent", children: [plan.trialDays, "-day free trial, if this workspace has not subscribed to this application before."] })) : null, coupon?.trim() ? (_jsxs("p", { className: "text-xs text-muted", children: ["Coupon ", _jsx("span", { className: "font-mono text-foreground", children: coupon.trim() }), " is carried over and validated on Connect."] })) : null, _jsxs("div", { className: "flex gap-2 rounded-lg bg-surface p-3 text-xs text-muted", children: [_jsx(Info, { className: "mt-0.5 size-4 shrink-0", "aria-hidden": "true" }), _jsxs("div", { className: "flex flex-col gap-1", children: [action === "subscribe" && (_jsx("p", { children: "Payment is completed on Connect, where your card or UPI mandate is set up and the tax invoice is issued. This page updates by itself once it is done." })), action === "upgrade" && (_jsxs("p", { children: ["Connect prices the difference for the days left in your current cycle and collects just that amount now. From the next cycle you are billed the ", plan.name, " rate."] })), action === "downgrade" && (_jsx("p", { children: "You keep what you have paid for. Connect works out the unused part of this cycle and applies it as credit or a refund, depending on how this application is configured." })), isIntervalSwitch && (
50
+ return (_jsx(Modal.Backdrop, { isOpen: plan !== null, onOpenChange: (open) => !open && onClose(), children: _jsx(Modal.Container, { size: "sm", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, { onPress: onClose }), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: HEADING[action] }) }), plan && (_jsxs(_Fragment, { children: [_jsxs(Modal.Body, { className: "flex flex-col gap-4", children: [isChange && currentPlan ? (_jsxs("div", { className: "flex items-center gap-3 text-sm", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-muted", children: currentPlan.name }), _jsxs("p", { className: "text-xs text-muted", children: [formatPrice(currentPlan.price), "/", intervalLabel(currentPlan.billingInterval).short] })] }), _jsx(ArrowRight, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate font-medium text-foreground", children: plan.name }), _jsxs("p", { className: "text-xs text-muted", children: [formatPrice(plan.price), "/", intervalLabel(plan.billingInterval).short] })] })] })) : (_jsxs("div", { children: [_jsx("p", { className: "font-medium text-foreground", children: plan.name }), plan.description && _jsx("p", { className: "text-xs text-muted", children: plan.description })] })), isFreePlan(plan.price) ? (_jsxs("div", { className: "flex justify-between border-t border-separator pt-3 text-sm font-semibold text-foreground", children: [_jsx("span", { children: plan.name }), _jsx("span", { children: "Free" })] })) : (_jsxs("div", { className: "flex flex-col gap-1 border-t border-separator pt-3 text-sm", children: [_jsxs("div", { className: "flex justify-between text-muted", children: [_jsx("span", { children: plan.name }), _jsx("span", { children: formatPrice(plan.price) })] }), _jsxs("div", { className: "flex justify-between text-muted", children: [_jsxs("span", { children: ["GST (", plan.gstRate, "%)"] }), _jsx("span", { children: formatPrice(withGst(plan.price, plan.gstRate) - plan.price) })] }), _jsxs("div", { className: "flex justify-between border-t border-separator pt-1 font-semibold text-foreground", children: [_jsxs("span", { children: ["Per ", cadenceOf(plan)] }), _jsx("span", { children: formatPrice(withGst(plan.price, plan.gstRate)) })] }), plan.billingInterval === "annual" && (_jsxs("p", { className: "text-xs text-muted", children: ["Works out to ", formatPrice(monthlyOf(withGst(plan.price, plan.gstRate), "annual")), " a month, billed yearly."] }))] })), plan.trialDays && !isFreePlan(plan.price) ? (_jsxs("p", { className: "text-xs text-accent", children: [plan.trialDays, "-day free trial, if this workspace has not subscribed to this application before."] })) : null, coupon?.trim() ? (_jsxs("p", { className: "text-xs text-muted", children: ["Coupon ", _jsx("span", { className: "font-mono text-foreground", children: coupon.trim() }), " is carried over and validated on Connect."] })) : null, _jsxs("div", { className: "flex gap-2 rounded-lg bg-surface p-3 text-xs text-muted", children: [_jsx(Info, { className: "mt-0.5 size-4 shrink-0", "aria-hidden": "true" }), _jsxs("div", { className: "flex flex-col gap-1", children: [action === "subscribe" &&
51
+ (isFreePlan(plan.price) ? (_jsx("p", { children: "This plan is free. Connect activates it straight away - there is no payment to make, no card or UPI mandate to set up, and no invoice to expect. This page updates by itself once it is done." })) : (_jsx("p", { children: "Payment is completed on Connect, where your card or UPI mandate is set up and the tax invoice is issued. This page updates by itself once it is done." }))), action === "upgrade" && (_jsxs("p", { children: ["Connect prices the difference for the days left in your current cycle and collects just that amount now. From the next cycle you are billed the ", plan.name, " rate."] })), action === "downgrade" && (_jsx("p", { children: "You keep what you have paid for. Connect works out the unused part of this cycle and applies it as credit or a refund, depending on how this application is configured." })), isIntervalSwitch && (
51
52
  // Not a proration. The cycle you are on ends, so this is
52
53
  // the one action where the amount collected can exceed a
53
54
  // single cycle's list price - or be nothing at all, with
@@ -105,6 +105,12 @@ export { ConnectCursorPagination } from "./components/cursor-pagination.js";
105
105
  export { ConnectFilterTabs } from "./components/filter-tabs.js";
106
106
  export { useConnectCheckout, buildCheckoutUrl } from "./use-checkout.js";
107
107
  export { formatPrice, monthlyOf, resolveAction, annualSaving, discountLabel, ACTION_LABEL,
108
+ // Exported for the same reason `groupPlansByTier` is: a partner writing its
109
+ // own pricing grid has to draw a ₹0 plan as "Free" rather than as a price
110
+ // with a cadence and a GST rate attached, and re-deriving "is this free"
111
+ // from a raw `price === 0` misses the add-on half of the rule Connect
112
+ // actually applies.
113
+ isFreePlan,
108
114
  // The tier model, exported because a partner writing its own pricing grid
109
115
  // needs the same pairing `ConnectPlans` uses. Grouping a flat plan list by
110
116
  // hand means matching on `name`, which Connect neither enforces unique nor
@@ -4,6 +4,7 @@ import { AiResource } from "./resources/ai.js";
4
4
  import { DataResource, storePassthrough } from "./resources/data.js";
5
5
  import { WorkspacesResource } from "./resources/workspaces.js";
6
6
  import { SchedulingResource } from "./resources/scheduling.js";
7
+ import { ContactsResource } from "./resources/contacts.js";
7
8
  import { KvResource } from "./resources/kv.js";
8
9
  import { BillingResource } from "./resources/billing.js";
9
10
  import { DriveResource } from "./resources/drive.js";
@@ -79,6 +80,8 @@ export class ConnectClient {
79
80
  drive;
80
81
  /** A subscribing workspace's own calendar - what is bookable, free, and booked. */
81
82
  scheduling;
83
+ /** Who a workspace already knows how to reach, by phone or by id. */
84
+ contacts;
82
85
  /** RAG chat and structured extraction. */
83
86
  ai;
84
87
  /** Your application's own document store - see `DataResource`. */
@@ -128,6 +131,7 @@ export class ConnectClient {
128
131
  this.billing = new BillingResource(transport);
129
132
  this.drive = new DriveResource(transport);
130
133
  this.scheduling = new SchedulingResource(transport);
134
+ this.contacts = new ContactsResource(transport);
131
135
  this.ai = new AiResource(transport);
132
136
  this.data = new DataResource(transport);
133
137
  this.kv = new KvResource(transport);
@@ -142,4 +146,4 @@ export class ConnectClient {
142
146
  }
143
147
  }
144
148
  /** Kept in step with `package.json` by `sdk.test.ts`. */
145
- export const SDK_VERSION = "0.18.0";
149
+ export const SDK_VERSION = "0.20.0";
@@ -0,0 +1,62 @@
1
+ import { request } from "../http.js";
2
+ import { actingAs } from "./acting-as.js";
3
+ /**
4
+ * Who a workspace already knows how to reach.
5
+ *
6
+ * **This is the telephony identity, not a CRM record.** A contact is keyed by
7
+ * `primaryPhone` - the one fact every inbound call carries - and it is written
8
+ * automatically: a voice agent creates one on the first call from a new number
9
+ * and fills fields in as it learns them. `source` says which kind you are
10
+ * holding, and it is worth rendering: `call` means an agent guessed and nobody
11
+ * has confirmed, `manual` means a person typed it.
12
+ *
13
+ * **Reference it, do not copy it.** If your application keeps its own customer
14
+ * records, store this contact's `id` on yours rather than duplicating the
15
+ * fields. Two writable copies of a person's name with no single owner drift,
16
+ * and Connect's is the one an agent keeps updating from calls. The id is also
17
+ * an exact join to anything else keyed by it - `Booking.contactId`, most
18
+ * usefully - so "which of my records is this appointment for" becomes a lookup
19
+ * instead of a phone match.
20
+ *
21
+ * Read-only for now. Writing into a store with a per-field provenance chain
22
+ * means declaring where your facts came from and how they merge with a
23
+ * human's, and nothing needs that yet.
24
+ */
25
+ export class ContactsResource {
26
+ transport;
27
+ constructor(transport) {
28
+ this.transport = transport;
29
+ }
30
+ /**
31
+ * "Do we already know this number?" - one point read, cheap enough for a
32
+ * screen pop on every inbound call.
33
+ *
34
+ * Resolves to `null` for a number nobody has called from before. That is the
35
+ * ordinary case for a first-time caller, not an error, which is why it is a
36
+ * null rather than a thrown `ConnectError`.
37
+ */
38
+ async lookupByPhone(input) {
39
+ const result = await request(this.transport, {
40
+ method: "GET",
41
+ path: "/contacts",
42
+ query: { workspaceId: input.workspaceId, phone: input.phone },
43
+ headers: actingAs(input.asUser),
44
+ });
45
+ return result.contact ?? null;
46
+ }
47
+ /**
48
+ * The record behind an id you already hold.
49
+ *
50
+ * Throws `ConnectError` with `code: "not_found"` for an id that does not
51
+ * exist *or* belongs to another workspace - deliberately the same answer,
52
+ * since telling those apart would confirm a contact exists somewhere else.
53
+ */
54
+ get(contactId, options) {
55
+ return request(this.transport, {
56
+ method: "GET",
57
+ path: `/contacts/${encodeURIComponent(contactId)}`,
58
+ query: { workspaceId: options.workspaceId },
59
+ headers: actingAs(options.asUser),
60
+ });
61
+ }
62
+ }
@@ -14,14 +14,21 @@ export interface ConnectAppsGridProps {
14
14
  variant?: "row" | "icon";
15
15
  }
16
16
  /**
17
- * The Google-style app launcher: a grid icon in your top bar that opens a
18
- * modal of every Connect application this workspace can actually open.
17
+ * The Google-style app launcher: a grid icon that opens every Connect
18
+ * application this workspace has, and every one it could have.
19
19
  *
20
- * Only launchable apps appear, so no tile is a dead end - a paused
21
- * subscription still shows, badged, because a dunning cycle should not make a
22
- * product look deleted. The current app is pinned first and marked, rather
23
- * than filtered out, so the grid never has a hole where "you are here" should
24
- * be.
20
+ * Two sections, because they are two different actions. **Your apps** is what
21
+ * the workspace can open right now - the current application pinned first and
22
+ * marked, so the grid never has a hole where "you are here" should be, and a
23
+ * paused subscription still listed but badged, because a dunning cycle should
24
+ * not make a product look deleted. **Explore** is the rest of the active
25
+ * catalog, minus anything this workspace's type could never subscribe to, and
26
+ * a tile there opens that application's page on Connect.
27
+ *
28
+ * It used to show subscriptions only, with a "Browse all applications" link
29
+ * under the grid pointing at a Connect route that does not exist - so a
30
+ * workspace with one subscription saw one tile and had no working way to find
31
+ * anything else. Discovery belongs in the grid.
25
32
  *
26
33
  * Renders nothing when signed out: a launcher with nothing to launch is just a
27
34
  * button that opens an empty box.
@@ -16,6 +16,19 @@
16
16
  * and nothing in it needs to be.
17
17
  */
18
18
  import type { AppliedDiscount, Plan, SubscriptionResponse } from "../../types.js";
19
+ /**
20
+ * Whether a plan costs nothing - the SDK's copy of Connect's `isFreeSelection`
21
+ * (`packages/data/src/payments/free-plan.ts`), which is what the server
22
+ * actually decides the free-activation path with.
23
+ *
24
+ * A copy rather than an import for the reason `types.ts` gives: the published
25
+ * package carries none of Connect's internals. The rule is one line and does
26
+ * not drift, and `conformance.test.ts` pins the shape either side of it.
27
+ *
28
+ * @param addonsTotal Paise of the add-ons *selected*, not of the plan's whole
29
+ * catalog. A free plan with a paid add-on ticked is not free.
30
+ */
31
+ export declare function isFreePlan(price: number, addonsTotal?: number): boolean;
19
32
  /** Paise are the wire unit everywhere in Connect; rupees are the display unit. */
20
33
  export declare function formatPrice(paise: number): string;
21
34
  /**
@@ -82,7 +82,7 @@ export { ConnectFilterTabs } from "./components/filter-tabs.js";
82
82
  export type { ConnectFilterTabsProps, ConnectFilterTabsOption } from "./components/filter-tabs.js";
83
83
  export { useConnectCheckout, buildCheckoutUrl } from "./use-checkout.js";
84
84
  export type { ConnectCheckoutStatus, ConnectCheckoutState, ConnectEntitlements, StartCheckoutOptions, CheckoutUrlOptions, } from "./use-checkout.js";
85
- export { formatPrice, monthlyOf, resolveAction, annualSaving, discountLabel, ACTION_LABEL, groupPlansByTier, resolvePlanGroup, } from "./components/plan-pricing.js";
85
+ export { formatPrice, monthlyOf, resolveAction, annualSaving, discountLabel, ACTION_LABEL, isFreePlan, groupPlansByTier, resolvePlanGroup, } from "./components/plan-pricing.js";
86
86
  export type { PlanAction, PlanTier, ComparablePlan } from "./components/plan-pricing.js";
87
87
  export { ConnectWorkspaceSwitcher } from "./components/workspace-switcher.js";
88
88
  export type { ConnectWorkspaceSwitcherProps } from "./components/workspace-switcher.js";
@@ -5,6 +5,7 @@ import { AiResource } from "./resources/ai.js";
5
5
  import { DataResource } from "./resources/data.js";
6
6
  import { WorkspacesResource } from "./resources/workspaces.js";
7
7
  import { SchedulingResource } from "./resources/scheduling.js";
8
+ import { ContactsResource } from "./resources/contacts.js";
8
9
  import { KvResource } from "./resources/kv.js";
9
10
  import { BillingResource } from "./resources/billing.js";
10
11
  import { DriveResource } from "./resources/drive.js";
@@ -78,6 +79,8 @@ export declare class ConnectClient {
78
79
  readonly drive: DriveResource;
79
80
  /** A subscribing workspace's own calendar - what is bookable, free, and booked. */
80
81
  readonly scheduling: SchedulingResource;
82
+ /** Who a workspace already knows how to reach, by phone or by id. */
83
+ readonly contacts: ContactsResource;
81
84
  /** RAG chat and structured extraction. */
82
85
  readonly ai: AiResource;
83
86
  /** Your application's own document store - see `DataResource`. */
@@ -112,4 +115,4 @@ export declare class ConnectClient {
112
115
  constructor(options: ConnectClientOptions);
113
116
  }
114
117
  /** Kept in step with `package.json` by `sdk.test.ts`. */
115
- export declare const SDK_VERSION = "0.18.0";
118
+ export declare const SDK_VERSION = "0.20.0";
@@ -28,6 +28,7 @@ export type { SessionsResource } from "./resources/sessions.js";
28
28
  export type { ExecuteIntegrationActionInput, IntegrationsResource } from "./resources/integrations.js";
29
29
  export type { PlatformResource } from "./resources/platform.js";
30
30
  export type { SchedulingResource, FindSlotsInput, ListBookingsInput, CreateBookingInput, RescheduleBookingInput, } from "./resources/scheduling.js";
31
+ export type { ContactsResource, LookupContactInput } from "./resources/contacts.js";
31
32
  /**
32
33
  * Webhook verification and a ready-made POST route. Server-only for the
33
34
  * obvious reason: the signing secret must never reach a browser bundle.
@@ -0,0 +1,60 @@
1
+ import { type TransportOptions } from "../http.js";
2
+ import type { ConnectContact } from "../../types.js";
3
+ export interface LookupContactInput {
4
+ workspaceId: string;
5
+ /**
6
+ * Any reasonable spelling. `+919000000001`, `919000000001` and
7
+ * `+91 90000 00001` all resolve to the same contact - the number is
8
+ * normalized to E.164 before the lookup, because comparing unnormalized is
9
+ * how a contact that exists reads as absent.
10
+ */
11
+ phone: string;
12
+ /** See `ActingAsOption` on the scheduling resource - same contract. */
13
+ asUser?: string;
14
+ }
15
+ /**
16
+ * Who a workspace already knows how to reach.
17
+ *
18
+ * **This is the telephony identity, not a CRM record.** A contact is keyed by
19
+ * `primaryPhone` - the one fact every inbound call carries - and it is written
20
+ * automatically: a voice agent creates one on the first call from a new number
21
+ * and fills fields in as it learns them. `source` says which kind you are
22
+ * holding, and it is worth rendering: `call` means an agent guessed and nobody
23
+ * has confirmed, `manual` means a person typed it.
24
+ *
25
+ * **Reference it, do not copy it.** If your application keeps its own customer
26
+ * records, store this contact's `id` on yours rather than duplicating the
27
+ * fields. Two writable copies of a person's name with no single owner drift,
28
+ * and Connect's is the one an agent keeps updating from calls. The id is also
29
+ * an exact join to anything else keyed by it - `Booking.contactId`, most
30
+ * usefully - so "which of my records is this appointment for" becomes a lookup
31
+ * instead of a phone match.
32
+ *
33
+ * Read-only for now. Writing into a store with a per-field provenance chain
34
+ * means declaring where your facts came from and how they merge with a
35
+ * human's, and nothing needs that yet.
36
+ */
37
+ export declare class ContactsResource {
38
+ private readonly transport;
39
+ constructor(transport: TransportOptions);
40
+ /**
41
+ * "Do we already know this number?" - one point read, cheap enough for a
42
+ * screen pop on every inbound call.
43
+ *
44
+ * Resolves to `null` for a number nobody has called from before. That is the
45
+ * ordinary case for a first-time caller, not an error, which is why it is a
46
+ * null rather than a thrown `ConnectError`.
47
+ */
48
+ lookupByPhone(input: LookupContactInput): Promise<ConnectContact | null>;
49
+ /**
50
+ * The record behind an id you already hold.
51
+ *
52
+ * Throws `ConnectError` with `code: "not_found"` for an id that does not
53
+ * exist *or* belongs to another workspace - deliberately the same answer,
54
+ * since telling those apart would confirm a contact exists somewhere else.
55
+ */
56
+ get(contactId: string, options: {
57
+ workspaceId: string;
58
+ asUser?: string;
59
+ }): Promise<ConnectContact>;
60
+ }
@@ -549,13 +549,26 @@ export interface WorkspaceApp {
549
549
  iconUrl: string | null;
550
550
  /** A `lucide-react` icon name from Connect's curated set. */
551
551
  icon: string;
552
+ /** Catalog classification, if you want to group or filter the grid by it. */
553
+ category: WorkspaceAppCategory;
552
554
  /** Where a tile navigates. `null` when the application registered no site. */
553
555
  launchUrl: string | null;
556
+ /**
557
+ * Whether this workspace holds a launchable subscription.
558
+ *
559
+ * `/apps` answers with the platform's whole active catalog, not only what
560
+ * the workspace bought, so this is the split a launcher renders as two
561
+ * groups: the apps you open, and the apps you could. `false` means the tile
562
+ * belongs in the second - send it to Connect, not to `launchUrl`.
563
+ */
564
+ subscribed: boolean;
554
565
  /** True for the application whose API key made the call - render it as the active tile. */
555
566
  isCurrent: boolean;
556
- /** Launchable but not healthy (paused, or cancelling at period end) - badge it, do not hide it. */
567
+ /** Launchable but not healthy (paused, or cancelling at period end) - badge it, do not hide it. Always `false` when `subscribed` is `false`. */
557
568
  needsAttention: boolean;
558
569
  }
570
+ /** Connect's closed set of catalog categories - `WorkspaceApp.category`. */
571
+ export type WorkspaceAppCategory = "communication" | "storage" | "analytics" | "security" | "billing" | "productivity" | "sales" | "operations" | "infrastructure" | "other";
559
572
  /**
560
573
  * The platform's DPDP grievance channel - `companyProfile.grievance`.
561
574
  *
@@ -1224,3 +1237,48 @@ export interface ListBookingsPage {
1224
1237
  hasMore: boolean;
1225
1238
  totalCount: number;
1226
1239
  }
1240
+ /**
1241
+ * A phone or email on a contact.
1242
+ *
1243
+ * `verified` means a human confirmed it reaches them - only a manual source
1244
+ * may ever set it. Branch on it: an unverified value is one an extraction
1245
+ * heard on a call and nobody has checked since.
1246
+ */
1247
+ export interface ContactChannelValue {
1248
+ value: string;
1249
+ label: string | null;
1250
+ verified: boolean;
1251
+ }
1252
+ /**
1253
+ * How a contact came to exist. `call` means a voice agent created it from an
1254
+ * inbound call; `manual` and `import` are the two a human stands behind.
1255
+ */
1256
+ export type ContactSource = "call" | "manual" | "import" | "api" | "extraction";
1257
+ /**
1258
+ * Someone a workspace knows how to reach - **the telephony identity**, not a
1259
+ * CRM record. See `ContactsResource` for why the difference decides whether
1260
+ * you reference this or copy it.
1261
+ */
1262
+ export interface ConnectContact {
1263
+ id: string;
1264
+ /** E.164, and immutable - the key this contact is identified by. */
1265
+ primaryPhone: string;
1266
+ phones: ContactChannelValue[];
1267
+ emails: ContactChannelValue[];
1268
+ name: {
1269
+ display: string | null;
1270
+ given: string | null;
1271
+ family: string | null;
1272
+ };
1273
+ business: {
1274
+ company: string | null;
1275
+ role: string | null;
1276
+ gstin: string | null;
1277
+ website: string | null;
1278
+ address: string | null;
1279
+ };
1280
+ tags: string[];
1281
+ source: ContactSource;
1282
+ createdAt: string;
1283
+ updatedAt: string;
1284
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmoops/connect-sdk",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Official server-side SDK for the CosmoOps Connect platform API - Login with Connect, entitlements, billing and metering.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "commonjs",