@cosmoops/connect-sdk 0.19.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 +41 -0
- package/README.md +14 -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 +1 -1
- 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 +1 -1
- 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 +1 -1
- package/dist/types/types.d.ts +14 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,47 @@
|
|
|
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
|
+
|
|
6
47
|
## 0.19.0
|
|
7
48
|
|
|
8
49
|
### 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.
|
|
@@ -597,7 +597,7 @@ import {
|
|
|
597
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`.) |
|
|
598
598
|
| `ConnectProfileModal` | The same modal with no trigger, driven by `isOpen`/`onOpenChange` — for an app keeping its own account control. |
|
|
599
599
|
| `ConnectWorkspaceSwitcher` | Sits in the rail's header. Hides itself for an account with one workspace; shows unusable ones disabled, with the reason. |
|
|
600
|
-
| `ConnectAppsGrid` | Google-style launcher — grid icon opening a modal of
|
|
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. |
|
|
601
601
|
| `ConnectPlans` | Monthly/annual toggle, features, add-ons, live discount, coupon field, and the subscribe / upgrade / downgrade / cancel flow. |
|
|
602
602
|
| `ConnectPlansPage` | A whole `/plans` page — readiness banner, current subscription, and the grid. What `connect-sdk init` scaffolds. |
|
|
603
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. |
|
|
@@ -899,6 +899,18 @@ So the hand-off is a URL, and Connect renders it as a modal over the relevant pa
|
|
|
899
899
|
|
|
900
900
|
Full history: [CHANGELOG.md](./CHANGELOG.md). Latest first:
|
|
901
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
|
+
|
|
902
914
|
### 0.12.0 — breaking
|
|
903
915
|
|
|
904
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
|
|
@@ -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
|
|
@@ -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";
|
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
|
*
|
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",
|