@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 +83 -0
- package/README.md +15 -2
- package/dist/cjs/client/components/apps-grid.js +112 -23
- package/dist/cjs/client/components/plan-pricing.js +16 -0
- package/dist/cjs/client/components/plans.js +1 -1
- package/dist/cjs/client/components/subscribe-dialog.js +2 -1
- package/dist/cjs/client/index.js +7 -1
- package/dist/cjs/server/client.js +5 -1
- package/dist/cjs/server/resources/contacts.js +66 -0
- package/dist/esm/client/components/apps-grid.js +115 -26
- package/dist/esm/client/components/plan-pricing.js +15 -0
- package/dist/esm/client/components/plans.js +3 -3
- package/dist/esm/client/components/subscribe-dialog.js +3 -2
- package/dist/esm/client/index.js +6 -0
- package/dist/esm/server/client.js +5 -1
- package/dist/esm/server/resources/contacts.js +62 -0
- package/dist/types/client/components/apps-grid.d.ts +14 -7
- package/dist/types/client/components/plan-pricing.d.ts +13 -0
- package/dist/types/client/index.d.ts +1 -1
- package/dist/types/server/client.d.ts +4 -1
- package/dist/types/server/index.d.ts +1 -0
- package/dist/types/server/resources/contacts.d.ts +60 -0
- package/dist/types/types.d.ts +59 -1
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 (
|
|
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
|
|
26
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
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" &&
|
|
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
|
package/dist/cjs/client/index.js
CHANGED
|
@@ -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.
|
|
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,
|
|
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 {
|
|
7
|
+
import { m, MotionProvider } from "./motion.js";
|
|
7
8
|
import { SidebarLabel, sidebarRowClass } from "./shell-context.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 (
|
|
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
|
|
23
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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:
|
|
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" &&
|
|
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
|
package/dist/esm/client/index.js
CHANGED
|
@@ -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.
|
|
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
|
|
18
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
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
|
+
}
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
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",
|