@cosmoops/connect-sdk 0.35.0 → 0.37.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 +157 -0
- package/dist/cjs/client/components/app-shell.js +1 -1
- package/dist/cjs/client/components/plan-pricing.js +9 -4
- package/dist/cjs/client/components/plans.js +9 -1
- package/dist/cjs/client/components/workspace-switcher.js +23 -13
- package/dist/cjs/next/index.js +40 -0
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/resources/workspaces.js +33 -0
- package/dist/esm/client/components/app-shell.js +1 -1
- package/dist/esm/client/components/plan-pricing.js +9 -4
- package/dist/esm/client/components/plans.js +9 -1
- package/dist/esm/client/components/workspace-switcher.js +26 -16
- package/dist/esm/next/index.js +39 -0
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/resources/workspaces.js +33 -0
- package/dist/types/client/components/app-shell.d.ts +7 -1
- package/dist/types/client/components/plan-pricing.d.ts +10 -0
- package/dist/types/client/components/workspace-switcher.d.ts +25 -6
- package/dist/types/next/index.d.ts +38 -0
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/resources/scheduling.d.ts +6 -1
- package/dist/types/server/resources/workspaces.d.ts +29 -1
- package/dist/types/types.d.ts +76 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,142 @@
|
|
|
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.37.0
|
|
7
|
+
|
|
8
|
+
### The workspace switcher can stay visible at one workspace, and offer a second
|
|
9
|
+
|
|
10
|
+
`ConnectWorkspaceSwitcher` has always hidden itself below two workspaces -
|
|
11
|
+
right, since a switcher with one option teaches nothing. But a partner with no
|
|
12
|
+
way to *create* a workspace from its own chrome had no way to help somebody
|
|
13
|
+
discover that a second one is possible, either. `alwaysShow` is the opt-in:
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
<AppShell workspaceSwitcher={{ alwaysShow: true }} /* ... */ />
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Off by default, so every existing host keeps today's behaviour. On, the
|
|
20
|
+
dropdown stays mounted below two workspaces and gains a "Create workspace"
|
|
21
|
+
row that opens Connect - there is still no `workspaces.create()` on the SDK,
|
|
22
|
+
deliberately: creating one is tied to KYC/KYB and a billing profile, which is
|
|
23
|
+
Connect's job and not something an API key should be able to trigger.
|
|
24
|
+
|
|
25
|
+
### A one-time plan no longer renews under the annual toggle
|
|
26
|
+
|
|
27
|
+
`groupPlansByTier` filed a `billingInterval: "one-time"` plan as that tier's
|
|
28
|
+
*annual* plan, because `PlanTier` only ever had two slots. For a tier that
|
|
29
|
+
also sold a recurring plan under the same `planGroup`, that meant `annualSaving`
|
|
30
|
+
compared a monthly charge against a one-time lump sum and produced a bogus
|
|
31
|
+
"Save X%" badge, and switching `ConnectPlans`'s toggle to annual showed the
|
|
32
|
+
one-time purchase as if it were this year's discounted rate on a plan that
|
|
33
|
+
renews.
|
|
34
|
+
|
|
35
|
+
`PlanTier.oneTime` is the fix - its own slot, which `annualSaving` and the
|
|
36
|
+
annual/monthly toggle never read from. A tier that is *only* a one-time plan
|
|
37
|
+
ignores the toggle entirely now, rather than happening to work by the other
|
|
38
|
+
slot being empty.
|
|
39
|
+
|
|
40
|
+
## 0.36.0
|
|
41
|
+
|
|
42
|
+
### A workspace's billing details can be read, so a tax id is typed once
|
|
43
|
+
|
|
44
|
+
`workspaces.billingProfile({ asUser })` returns the legal name, GSTIN,
|
|
45
|
+
registered address and entity form the customer gave CosmoOps so we can
|
|
46
|
+
invoice them.
|
|
47
|
+
|
|
48
|
+
It exists for one shape of problem: an application that prints documents - a
|
|
49
|
+
CRM's quotation letterhead - needs the same three facts about the same legal
|
|
50
|
+
entity, and the only way to get them was to ask the customer again. Two copies
|
|
51
|
+
of a GSTIN diverge, and on an Indian quotation the first two characters of that
|
|
52
|
+
GSTIN are what decide CGST+SGST versus IGST.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const token = await getConnectSessionToken();
|
|
56
|
+
const billing = token ? await connect.workspaces.billingProfile({ asUser: token }) : null;
|
|
57
|
+
// offer "Use my billing details" — then keep your own copy
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**It is a prefill source, not a shared record.** This is the *Bill To* side -
|
|
61
|
+
who we invoice - and your letterhead is the *Bill From* side. They are the same
|
|
62
|
+
facts for most customers without being the same field: a group that bills us
|
|
63
|
+
through a holding company while quoting under a trading subsidiary has to stay
|
|
64
|
+
expressible. So copy it on first setup and keep your copy authoritative. There
|
|
65
|
+
is deliberately **no write form** - an application must not edit the details
|
|
66
|
+
its own invoices are raised against.
|
|
67
|
+
|
|
68
|
+
**Nothing in it is verified.** It is self-declared in the customer's billing
|
|
69
|
+
settings, not the KYB record; no PAN check stands behind it. A convenience,
|
|
70
|
+
never evidence of identity.
|
|
71
|
+
|
|
72
|
+
**It needs `billing:read` on the signed-in member**, on top of `workspaces:read`
|
|
73
|
+
on your key. A tax identity belongs to whoever can already see it in Connect's
|
|
74
|
+
billing screens - an owner, admin or manager - and it cannot be granted through
|
|
75
|
+
a role on your application. So an ordinary member gets a `403` here where they
|
|
76
|
+
get a roster from `members()`: **treat that as a normal branch**, offer the
|
|
77
|
+
prefill to whoever can use it, and let everyone else type their own details.
|
|
78
|
+
|
|
79
|
+
`razorpayCustomerId` is not returned. It is a payment-provider handle no
|
|
80
|
+
partner has a use for.
|
|
81
|
+
|
|
82
|
+
### `getConnectSessionToken()`, so `asUser` works from a Server Component
|
|
83
|
+
|
|
84
|
+
Every `asUser` call needs the caller's opaque session token, and there was no
|
|
85
|
+
way to obtain one inside a Server Component: `getConnectSession()` answers
|
|
86
|
+
*who* the caller is and deliberately does not hand back the credential. A page
|
|
87
|
+
that wanted its own workspace's roster had to reimplement the cookie read or
|
|
88
|
+
route the call through the browser.
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
const token = await getConnectSessionToken();
|
|
92
|
+
const members = token ? await connect.workspaces.members({ asUser: token }) : [];
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**It is the cookie, unverified** - no round trip, and no check that the session
|
|
96
|
+
is still live. A non-null answer means a cookie was present, not that it works;
|
|
97
|
+
Connect is what answers `401` for a revoked one. Use `getConnectSession()` for
|
|
98
|
+
"is somebody here" and this for "hand Connect the credential they arrived
|
|
99
|
+
with".
|
|
100
|
+
|
|
101
|
+
### `SDK_VERSION` matched the published version again
|
|
102
|
+
|
|
103
|
+
It still read `0.35.0` after the version bump, so every request from this
|
|
104
|
+
release would have carried a `User-Agent` a version behind. `src/sdk.test.ts`
|
|
105
|
+
is what caught it, and is the reason the constant is pinned to
|
|
106
|
+
`package.json` rather than trusted.
|
|
107
|
+
|
|
108
|
+
### A diary and a roster could not be joined
|
|
109
|
+
|
|
110
|
+
One field, `ConnectWorkspaceMember.memberId`, without which no integration
|
|
111
|
+
could say who an appointment belongs to.
|
|
112
|
+
|
|
113
|
+
`Booking.hostMemberIds` holds `WorkspaceMember.id` values, and so does
|
|
114
|
+
`listBookings`' `hostMemberId` filter. `workspaces.members()` returned
|
|
115
|
+
`userId` and no member id at all - so the two sets of ids had nothing in
|
|
116
|
+
common, and a partner could filter a diary by a value it had no way to read
|
|
117
|
+
back.
|
|
118
|
+
|
|
119
|
+
That made two obvious features unreachable rather than merely awkward: a host
|
|
120
|
+
column on a calendar, and narrowing a shared calendar to one person. Both fail
|
|
121
|
+
*quietly* if you reach for `userId` instead, because a filter nobody matches
|
|
122
|
+
returns an empty window, which reads as a free afternoon rather than a bug.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const { members } = await connect.workspaces.members({ asUser });
|
|
126
|
+
const byMemberId = new Map(members.map((m) => [m.memberId, m]));
|
|
127
|
+
|
|
128
|
+
const { bookings } = await connect.scheduling.listBookings({ workspaceId, asUser });
|
|
129
|
+
bookings.map((b) => b.hostMemberIds.map((id) => byMemberId.get(id)?.displayName));
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The two ids are not interchangeable and neither is derived from the other:
|
|
133
|
+
`userId` identifies the person everywhere else in this API, `memberId` is what
|
|
134
|
+
the calendar speaks in. It is workspace-scoped and opaque - do not address a
|
|
135
|
+
member by it anywhere else.
|
|
136
|
+
|
|
137
|
+
Additive, so nothing breaks. `Booking.hostMemberIds`' own doc comment claimed
|
|
138
|
+
these were already the ids `workspaces.listMembers` returns, which was wrong
|
|
139
|
+
twice over - that method is `workspaces.members()`, and until now its rows
|
|
140
|
+
carried no id these could be matched against.
|
|
141
|
+
|
|
6
142
|
## 0.35.0
|
|
7
143
|
|
|
8
144
|
One addition, `connectProviderProps()`, for a mistake both applications built
|
|
@@ -92,6 +228,27 @@ in the output. A note rather than a failure, so it cannot break a CI run: the
|
|
|
92
228
|
prop may be threaded from the layout into a `"use client"` shell, which reading
|
|
93
229
|
the layout cannot see.
|
|
94
230
|
|
|
231
|
+
### The rail fits a ten-destination nav on a laptop
|
|
232
|
+
|
|
233
|
+
`AppShell`'s nav rows, its launcher and its secondary-nav rows are **36px**
|
|
234
|
+
rather than 40px, and the one region that scrolls (`ConnectSidebarBody`) carries
|
|
235
|
+
`py-1` rather than `py-2`. The two rows with a 32px glyph - the account row and
|
|
236
|
+
the workspace switcher - stay at 40px: there are exactly two of them, neither
|
|
237
|
+
grows with your nav, and both need the height (a 32px tile in a 36px row has 2px
|
|
238
|
+
of air, and the account row's name-over-email label measures ~32px on its own).
|
|
239
|
+
|
|
240
|
+
Ten destinations at 40px came to 436px of nav against 273px of fixed chrome,
|
|
241
|
+
which needs a 709px viewport to sit still - a 1366x768 screen has about 678px of
|
|
242
|
+
it, so the nav scrolled on a perfectly ordinary laptop. Four pixels a row is 48px
|
|
243
|
+
back across the twelve rows a real integration draws. 36px is still comfortably
|
|
244
|
+
past WCAG 2.5.8's 24px target minimum, with the row's full width as the hit area.
|
|
245
|
+
|
|
246
|
+
The glyph box is deliberately unchanged at 20px. It is a flex centring container
|
|
247
|
+
for an icon **you** supply at your own size, so shrinking it buys no vertical
|
|
248
|
+
space and only pushes the icon off the 32px centre the collapsed rail is built
|
|
249
|
+
on. If you pass an icon larger than 20px it will now sit in a slightly tighter
|
|
250
|
+
row; `size-5` remains the recommendation.
|
|
251
|
+
|
|
95
252
|
### Fixed: the User-Agent reported the wrong version
|
|
96
253
|
|
|
97
254
|
`SDK_VERSION` was left at `0.34.0` when 0.34.1 shipped, so every request this
|
|
@@ -197,6 +197,6 @@ function AppShell({ name, icon, homeHref = "/", nav, secondaryNav, activePath, r
|
|
|
197
197
|
onNavigate: closeMobile,
|
|
198
198
|
}), [renderLink, activePath, closeMobile]);
|
|
199
199
|
const accountRow = signedOut ? (showAuthButtons && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2", children: [(0, jsx_runtime_1.jsx)(auth_buttons_js_1.ConnectLoginButton, { size: "sm", fullWidth: true }), (0, jsx_runtime_1.jsx)(auth_buttons_js_1.ConnectRegisterButton, { size: "sm", fullWidth: true })] }))) : ((0, jsx_runtime_1.jsx)(user_menu_js_1.ConnectUserButton, { variant: "row", theme: theme, onThemeChange: onThemeChange }));
|
|
200
|
-
const railContent = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebarHeader, { name: name, icon: icon, homeHref: homeHref }), workspaceSwitcher !== false && ((0, jsx_runtime_1.jsx)("div", { className: "shrink-0 px-3 pb-1", children: (0, jsx_runtime_1.jsx)(workspace_switcher_js_1.ConnectWorkspaceSwitcher, {}) })), (0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebarBody, { children: (0, jsx_runtime_1.jsx)(side_nav_js_1.ConnectSidebarNav, { nav: visibleNav }) }), (visibleSecondaryNav || sidebarFooter || showAppsGrid) && ((0, jsx_runtime_1.jsxs)(sidebar_js_1.ConnectSidebarFooter, { separated: false, children: [visibleSecondaryNav && (0, jsx_runtime_1.jsx)(side_nav_js_1.ConnectSidebarNav, { nav: visibleSecondaryNav }), sidebarFooter, showAppsGrid && (0, jsx_runtime_1.jsx)(apps_grid_js_1.ConnectAppsGrid, { variant: "row" })] })), accountRow && (0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebarFooter, { children: accountRow })] }));
|
|
200
|
+
const railContent = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebarHeader, { name: name, icon: icon, homeHref: homeHref }), workspaceSwitcher !== false && ((0, jsx_runtime_1.jsx)("div", { className: "shrink-0 px-3 pb-1", children: (0, jsx_runtime_1.jsx)(workspace_switcher_js_1.ConnectWorkspaceSwitcher, { alwaysShow: workspaceSwitcher?.alwaysShow ?? false }) })), (0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebarBody, { children: (0, jsx_runtime_1.jsx)(side_nav_js_1.ConnectSidebarNav, { nav: visibleNav }) }), (visibleSecondaryNav || sidebarFooter || showAppsGrid) && ((0, jsx_runtime_1.jsxs)(sidebar_js_1.ConnectSidebarFooter, { separated: false, children: [visibleSecondaryNav && (0, jsx_runtime_1.jsx)(side_nav_js_1.ConnectSidebarNav, { nav: visibleSecondaryNav }), sidebarFooter, showAppsGrid && (0, jsx_runtime_1.jsx)(apps_grid_js_1.ConnectAppsGrid, { variant: "row" })] })), accountRow && (0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebarFooter, { children: accountRow })] }));
|
|
201
201
|
return ((0, jsx_runtime_1.jsx)(motion_js_1.MotionProvider, { children: (0, jsx_runtime_1.jsxs)("div", { className: (0, cn_js_1.cn)("flex h-dvh bg-background", className), children: [(0, jsx_runtime_1.jsx)("a", { href: `#${CONTENT_ID}`, className: "sr-only focus:not-sr-only focus:absolute focus:start-3 focus:top-3 focus:z-50 focus:rounded-lg focus:bg-surface focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-lg focus:outline-2 focus:outline-offset-2 focus:outline-focus", children: "Skip to content" }), (0, jsx_runtime_1.jsx)(shell_context_js_1.ConnectShellProvider, { value: railShell, children: (0, jsx_runtime_1.jsx)(sidebar_js_1.ConnectSidebar, { children: railContent }) }), (0, jsx_runtime_1.jsx)(react_2.Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: (0, jsx_runtime_1.jsx)(react_2.Drawer.Content, { placement: "left", className: "w-[min(20rem,85vw)]", children: (0, jsx_runtime_1.jsxs)(react_2.Drawer.Dialog, { "aria-label": "Navigation menu", className: "p-0 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]", children: [(0, jsx_runtime_1.jsx)(react_2.Drawer.CloseTrigger, { className: "end-3 top-[calc(--spacing(3)+env(safe-area-inset-top))]" }), (0, jsx_runtime_1.jsx)(shell_context_js_1.ConnectShellProvider, { value: drawerShell, children: (0, jsx_runtime_1.jsx)("div", { className: "flex h-full flex-col [&>:first-child]:pe-10", children: railContent }) })] }) }) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-1 flex-col", children: [(0, jsx_runtime_1.jsxs)("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border bg-surface px-3 md:hidden", children: [(0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Menu, { className: "size-5" }) }), (0, jsx_runtime_1.jsx)(logo_js_1.ConnectLogo, { name: name, icon: icon })] }), (0, jsx_runtime_1.jsx)("main", { id: CONTENT_ID, tabIndex: -1, className: "flex-1 overflow-y-auto outline-none", children: (0, jsx_runtime_1.jsxs)("div", { className: contentClassName ?? "w-full px-4 py-5 md:px-6 md:py-6", children: [gated ? (0, jsx_runtime_1.jsx)(access_gate_js_1.ConnectAccessGate, { children: children }) : children, footer] }) })] }), askAi && (0, jsx_runtime_1.jsx)(ask_ai_js_1.ConnectAskAi, { ...(askAi === true ? {} : askAi) })] }) }));
|
|
202
202
|
}
|
|
@@ -140,20 +140,25 @@ function groupPlansByTier(plans) {
|
|
|
140
140
|
description: plan.description,
|
|
141
141
|
monthly: null,
|
|
142
142
|
annual: null,
|
|
143
|
+
oneTime: null,
|
|
143
144
|
annualSavingPercent: null,
|
|
144
145
|
};
|
|
145
146
|
if (plan.billingInterval === "monthly") {
|
|
146
147
|
if (!tier.monthly || plan.price < tier.monthly.price)
|
|
147
148
|
tier.monthly = plan;
|
|
148
149
|
}
|
|
149
|
-
else if (
|
|
150
|
-
tier.annual
|
|
150
|
+
else if (plan.billingInterval === "annual") {
|
|
151
|
+
if (!tier.annual || plan.price < tier.annual.price)
|
|
152
|
+
tier.annual = plan;
|
|
153
|
+
}
|
|
154
|
+
else if (!tier.oneTime || plan.price < tier.oneTime.price) {
|
|
155
|
+
tier.oneTime = plan;
|
|
151
156
|
}
|
|
152
157
|
tiers.set(planGroup, tier);
|
|
153
158
|
}
|
|
154
159
|
return [...tiers.values()]
|
|
155
160
|
.map((tier) => {
|
|
156
|
-
const identity = tier.monthly ?? tier.annual;
|
|
161
|
+
const identity = tier.monthly ?? tier.annual ?? tier.oneTime;
|
|
157
162
|
return {
|
|
158
163
|
...tier,
|
|
159
164
|
name: identity?.name ?? tier.name,
|
|
@@ -164,7 +169,7 @@ function groupPlansByTier(plans) {
|
|
|
164
169
|
.sort((a, b) => tierFloorPrice(a) - tierFloorPrice(b));
|
|
165
170
|
}
|
|
166
171
|
function tierFloorPrice(tier) {
|
|
167
|
-
const prices = [tier.monthly, tier.annual]
|
|
172
|
+
const prices = [tier.monthly, tier.annual, tier.oneTime]
|
|
168
173
|
.filter((plan) => plan !== null)
|
|
169
174
|
.map((plan) => monthlyOf(plan.price, plan.billingInterval));
|
|
170
175
|
return prices.length > 0 ? Math.min(...prices) : Number.MAX_SAFE_INTEGER;
|
|
@@ -129,7 +129,9 @@ function ConnectPlans({ planIds, hideCoupon = false, className }) {
|
|
|
129
129
|
// Growth tier, and marking only the row it literally subscribes to
|
|
130
130
|
// would leave its own card unmarked half the time.
|
|
131
131
|
const isCurrentTier = subscription?.plan != null &&
|
|
132
|
-
(tier.monthly?.id === subscription.plan.id ||
|
|
132
|
+
(tier.monthly?.id === subscription.plan.id ||
|
|
133
|
+
tier.annual?.id === subscription.plan.id ||
|
|
134
|
+
tier.oneTime?.id === subscription.plan.id);
|
|
133
135
|
const isCurrent = action === "current";
|
|
134
136
|
const cadenceUnavailable = showAnnual ? tier.annual === null : tier.monthly === null;
|
|
135
137
|
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) => (
|
|
@@ -214,7 +216,13 @@ function ConnectUnsubscribeRow({ subscription, appUrl, checkout, }) {
|
|
|
214
216
|
* monthly-only is a real thing (a free or entry plan usually is), and hiding
|
|
215
217
|
* it while a customer looks at annual pricing loses them an option that is
|
|
216
218
|
* still available to buy.
|
|
219
|
+
*
|
|
220
|
+
* A tier that is *only* a one-time plan ignores the toggle entirely - there
|
|
221
|
+
* is no cadence to switch between, so the price it shows must not depend on
|
|
222
|
+
* `showAnnual`.
|
|
217
223
|
*/
|
|
218
224
|
function pickTierPlan(tier, showAnnual) {
|
|
225
|
+
if (tier.oneTime && !tier.monthly && !tier.annual)
|
|
226
|
+
return tier.oneTime;
|
|
219
227
|
return showAnnual ? (tier.annual ?? tier.monthly) : (tier.monthly ?? tier.annual);
|
|
220
228
|
}
|
|
@@ -14,6 +14,9 @@ function workspaceInitials(name) {
|
|
|
14
14
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
15
15
|
return (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
|
|
16
16
|
}
|
|
17
|
+
function openExternal(url) {
|
|
18
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
19
|
+
}
|
|
17
20
|
/**
|
|
18
21
|
* Switches which workspace this application is looking at.
|
|
19
22
|
*
|
|
@@ -22,11 +25,15 @@ function workspaceInitials(name) {
|
|
|
22
25
|
* it reads as a heading for the nav rather than as one more destination.
|
|
23
26
|
*
|
|
24
27
|
* It decides for itself whether to appear: an account with one workspace is
|
|
25
|
-
* shown nothing, because a switcher offering one option teaches
|
|
26
|
-
* nothing and costs a row of chrome on every screen. That is the
|
|
27
|
-
* Connect's own switcher, which always renders - Connect is where
|
|
28
|
-
* is *created*, so hiding it at one workspace would guarantee
|
|
29
|
-
* reached two.
|
|
28
|
+
* shown nothing by default, because a switcher offering one option teaches
|
|
29
|
+
* the user nothing and costs a row of chrome on every screen. That is the
|
|
30
|
+
* opposite of Connect's own switcher, which always renders - Connect is where
|
|
31
|
+
* a workspace is *created*, so hiding it at one workspace would guarantee
|
|
32
|
+
* nobody ever reached two. `alwaysShow` is the escape hatch for a partner
|
|
33
|
+
* application that wants that same discoverability without becoming Connect:
|
|
34
|
+
* it keeps the dropdown open below two workspaces and adds a row that sends
|
|
35
|
+
* the member to Connect to actually create one, since there is nothing here
|
|
36
|
+
* that can.
|
|
30
37
|
*
|
|
31
38
|
* **Authorization is the data, not a prop.** The list comes from the caller's
|
|
32
39
|
* own session (`GET /workspaces` takes no user id), so a workspace the user
|
|
@@ -45,19 +52,22 @@ function workspaceInitials(name) {
|
|
|
45
52
|
* component - so the only thing to do afterwards is reload, which is what
|
|
46
53
|
* `onSwitched` defaults to.
|
|
47
54
|
*/
|
|
48
|
-
function ConnectWorkspaceSwitcher({ onSwitched, row = true, className, }) {
|
|
49
|
-
const { mutateResource } = (0, provider_js_1.useConnect)();
|
|
55
|
+
function ConnectWorkspaceSwitcher({ onSwitched, row = true, className, alwaysShow = false, }) {
|
|
56
|
+
const { mutateResource, appUrl } = (0, provider_js_1.useConnect)();
|
|
50
57
|
const { collapsed } = (0, shell_context_js_1.useConnectShell)();
|
|
51
58
|
const user = (0, provider_js_1.useConnectUser)();
|
|
52
59
|
const { data, isLoading } = (0, provider_js_1.useConnectWorkspaces)();
|
|
53
60
|
const [switching, setSwitching] = (0, react_1.useState)(null);
|
|
54
61
|
const [error, setError] = (0, react_1.useState)(null);
|
|
55
62
|
const workspaces = data?.workspaces ?? [];
|
|
56
|
-
// Nothing
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
63
|
+
// Nothing loaded yet. Deliberately renders `null` rather than a skeleton:
|
|
64
|
+
// this sits in persistent chrome, and a placeholder that resolves to
|
|
65
|
+
// nothing for most accounts is worse than appearing a moment late.
|
|
66
|
+
//
|
|
67
|
+
// Below one workspace, `alwaysShow` is the only thing that keeps this
|
|
68
|
+
// rendering - the default stays "nothing to switch between" for every
|
|
69
|
+
// host that hasn't opted in.
|
|
70
|
+
if (isLoading || (!alwaysShow && workspaces.length < 2))
|
|
61
71
|
return null;
|
|
62
72
|
const active = workspaces.find((workspace) => workspace.isActive);
|
|
63
73
|
const label = active?.name ?? user?.workspaceName ?? "Workspace";
|
|
@@ -87,7 +97,7 @@ function ConnectWorkspaceSwitcher({ onSwitched, row = true, className, }) {
|
|
|
87
97
|
// A 32px tile rather than a 20px glyph, so the initials land on the
|
|
88
98
|
// same optical centre the avatar below them uses - see
|
|
89
99
|
// `SIDEBAR_ROW_TILE`. Collapsed, this tile *is* the switcher.
|
|
90
|
-
(0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "tertiary", "aria-label": `Workspace: ${label}. Switch workspace`, className: (0, shell_context_js_1.sidebarRowClass)({ tile: true, className }), children: [(0, jsx_runtime_1.jsx)("span", { className: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-xs font-semibold text-accent", children: workspaceInitials(label) }), (0, jsx_runtime_1.jsx)(shell_context_js_1.SidebarLabel, { className: "text-sm font-medium text-foreground", children: label }), (0, jsx_runtime_1.jsx)(motion_js_1.m.span, { className: "shrink-0 text-muted", animate: { opacity: collapsed ? 0 : 1 }, transition: collapsed ? { duration: 0.1 } : { duration: 0.18, delay: 0.1 }, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronsUpDown, { className: "size-4" }) })] })) : ((0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "secondary", size: "sm", className: "w-full justify-between", children: [(0, jsx_runtime_1.jsx)("span", { className: "truncate", children: label }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronsUpDown, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" })] })), (0, jsx_runtime_1.jsx)(react_2.Dropdown.Popover, { children: (0, jsx_runtime_1.
|
|
100
|
+
(0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "tertiary", "aria-label": `Workspace: ${label}. Switch workspace`, className: (0, shell_context_js_1.sidebarRowClass)({ tile: true, className }), children: [(0, jsx_runtime_1.jsx)("span", { className: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-xs font-semibold text-accent", children: workspaceInitials(label) }), (0, jsx_runtime_1.jsx)(shell_context_js_1.SidebarLabel, { className: "text-sm font-medium text-foreground", children: label }), (0, jsx_runtime_1.jsx)(motion_js_1.m.span, { className: "shrink-0 text-muted", animate: { opacity: collapsed ? 0 : 1 }, transition: collapsed ? { duration: 0.1 } : { duration: 0.18, delay: 0.1 }, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronsUpDown, { className: "size-4" }) })] })) : ((0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "secondary", size: "sm", className: "w-full justify-between", children: [(0, jsx_runtime_1.jsx)("span", { className: "truncate", children: label }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronsUpDown, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" })] })), (0, jsx_runtime_1.jsx)(react_2.Dropdown.Popover, { children: (0, jsx_runtime_1.jsxs)(react_2.ListBox, { children: [workspaces.map((workspace) => ((0, jsx_runtime_1.jsxs)(react_2.ListBox.Item, { id: workspace.workspaceId, textValue: workspace.name, isDisabled: !selectable(workspace) || switching !== null, onAction: () => void handleSwitch(workspace), children: [(0, jsx_runtime_1.jsxs)("div", { className: "min-w-0", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-sm", children: workspace.name }), selectable(workspace) ? null : ((0, jsx_runtime_1.jsx)("p", { className: "truncate text-xs text-muted", children: reasonUnavailable(workspace) }))] }), (0, jsx_runtime_1.jsx)(react_2.ListBox.ItemIndicator, {})] }, workspace.workspaceId))), alwaysShow ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [workspaces.length > 0 && (0, jsx_runtime_1.jsx)(react_2.Separator, {}), (0, jsx_runtime_1.jsxs)(react_2.ListBox.Item, { id: "__create_workspace", textValue: "Create workspace", onAction: () => openExternal(appUrl), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { className: "size-4 shrink-0 text-muted", "aria-hidden": true }), (0, jsx_runtime_1.jsx)("p", { className: "truncate text-sm", children: "Create workspace" })] })] })) : null] }) })] }), error && !collapsed ? (0, jsx_runtime_1.jsx)("p", { className: "mt-1 px-2 text-xs text-danger", children: error }) : null] }));
|
|
91
101
|
}
|
|
92
102
|
/** Belongs to the user, and usable here. The same two facts `switch` enforces. */
|
|
93
103
|
function selectable(workspace) {
|
package/dist/cjs/next/index.js
CHANGED
|
@@ -41,6 +41,7 @@ exports.cookieSessionStore = cookieSessionStore;
|
|
|
41
41
|
exports.createConnectHandlers = createConnectHandlers;
|
|
42
42
|
exports.GET = handle;
|
|
43
43
|
exports.POST = handle;
|
|
44
|
+
exports.getConnectSessionToken = getConnectSessionToken;
|
|
44
45
|
exports.getConnectSession = getConnectSession;
|
|
45
46
|
exports.requireConnectSession = requireConnectSession;
|
|
46
47
|
const react_1 = require("react");
|
|
@@ -352,6 +353,45 @@ async function readSession(request, options) {
|
|
|
352
353
|
const cachedAmbientSession = (0, react_1.cache)(async () => {
|
|
353
354
|
return readSession(await requestFromHeaders(), {});
|
|
354
355
|
});
|
|
356
|
+
/**
|
|
357
|
+
* The caller's opaque Connect **session token**, or `null` when signed out.
|
|
358
|
+
*
|
|
359
|
+
* ## What it is for
|
|
360
|
+
*
|
|
361
|
+
* Every `asUser` call on the server client needs one - `workspaces.*`,
|
|
362
|
+
* `scheduling.*`, the `/data` store's per-user form - and until this existed
|
|
363
|
+
* there was no way to obtain it inside a Server Component. `getConnectSession`
|
|
364
|
+
* answers *who* the caller is and deliberately does not hand back the
|
|
365
|
+
* credential, so a page that wanted to read its own workspace's roster had to
|
|
366
|
+
* either reimplement the cookie read or route the call through the browser.
|
|
367
|
+
*
|
|
368
|
+
* ```tsx
|
|
369
|
+
* // app/settings/page.tsx
|
|
370
|
+
* const token = await getConnectSessionToken();
|
|
371
|
+
* const members = token ? await connect.workspaces.members({ asUser: token }) : [];
|
|
372
|
+
* ```
|
|
373
|
+
*
|
|
374
|
+
* ## It is the cookie, unverified
|
|
375
|
+
*
|
|
376
|
+
* This reads the session store and nothing else - **no round trip, and no
|
|
377
|
+
* check that the token is still live.** A token from a revoked or expired
|
|
378
|
+
* session is a string that looks exactly like a good one, and Connect is what
|
|
379
|
+
* answers `401` for it. So this is not a sign-in check: use
|
|
380
|
+
* `getConnectSession()` for "is somebody here", and this for "hand Connect the
|
|
381
|
+
* credential they arrived with". A non-null answer means a cookie was present,
|
|
382
|
+
* not that it works.
|
|
383
|
+
*
|
|
384
|
+
* Reads `cookies()` transitively, so under `cacheComponents` it belongs inside
|
|
385
|
+
* a `<Suspense>` boundary like any other request-scoped read.
|
|
386
|
+
*
|
|
387
|
+
* Pass a `Request` to use it where `next/headers` does not exist, and a
|
|
388
|
+
* `session` store if you did not use the default cookie - the same two escape
|
|
389
|
+
* hatches `getConnectSession` takes, for the same reasons.
|
|
390
|
+
*/
|
|
391
|
+
async function getConnectSessionToken(request, options = {}) {
|
|
392
|
+
const store = options.session ?? cookieSessionStore();
|
|
393
|
+
return (await store.read(request ?? (await requestFromHeaders()))) ?? null;
|
|
394
|
+
}
|
|
355
395
|
/**
|
|
356
396
|
* The signed-in user for a Server Component, Route Handler or Server Action,
|
|
357
397
|
* or `null`.
|
|
@@ -54,6 +54,39 @@ class WorkspacesResource {
|
|
|
54
54
|
json: input,
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The billing identity of the workspace this session is on - its legal
|
|
59
|
+
* name, GSTIN, registered address and entity form.
|
|
60
|
+
*
|
|
61
|
+
* **For prefilling your own settings, once.** See
|
|
62
|
+
* `WorkspaceBillingProfile`, which carries the whole argument: this is the
|
|
63
|
+
* *Bill To* side, yours is the *Bill From* side, and they are the same facts
|
|
64
|
+
* for most customers without being the same field. There is no write form.
|
|
65
|
+
*
|
|
66
|
+
* ```ts
|
|
67
|
+
* // app/settings/page.tsx - offer "Use my billing details"
|
|
68
|
+
* const token = await getConnectSessionToken();
|
|
69
|
+
* const billing = token ? await connect.workspaces.billingProfile({ asUser: token }) : null;
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* **Needs two things, not one.** `workspaces:read` on your key, *and*
|
|
73
|
+
* `billing:read` on the signed-in member - a tax identity belongs to
|
|
74
|
+
* whoever can already see it in Connect's billing screens, so an ordinary
|
|
75
|
+
* member gets a 403 here where they get a roster from `members()`. That
|
|
76
|
+
* cannot be granted through a role on your application, deliberately.
|
|
77
|
+
*
|
|
78
|
+
* So **treat the 403 as a normal branch**, not an error: offer the prefill
|
|
79
|
+
* to whoever can use it and let everyone else type their own details. A
|
|
80
|
+
* workspace admin is the person who sets this up anyway.
|
|
81
|
+
*/
|
|
82
|
+
async billingProfile(options) {
|
|
83
|
+
const result = await (0, http_js_1.request)(this.transport, {
|
|
84
|
+
method: "GET",
|
|
85
|
+
path: "/workspaces/billing-profile",
|
|
86
|
+
headers: (0, acting_as_js_1.actingAs)(options.asUser),
|
|
87
|
+
});
|
|
88
|
+
return result.billingProfile;
|
|
89
|
+
}
|
|
57
90
|
/**
|
|
58
91
|
* The team of the workspace this session is on.
|
|
59
92
|
*
|
|
@@ -194,6 +194,6 @@ export function AppShell({ name, icon, homeHref = "/", nav, secondaryNav, active
|
|
|
194
194
|
onNavigate: closeMobile,
|
|
195
195
|
}), [renderLink, activePath, closeMobile]);
|
|
196
196
|
const accountRow = signedOut ? (showAuthButtons && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(ConnectLoginButton, { size: "sm", fullWidth: true }), _jsx(ConnectRegisterButton, { size: "sm", fullWidth: true })] }))) : (_jsx(ConnectUserButton, { variant: "row", theme: theme, onThemeChange: onThemeChange }));
|
|
197
|
-
const railContent = (_jsxs(_Fragment, { children: [_jsx(ConnectSidebarHeader, { name: name, icon: icon, homeHref: homeHref }), workspaceSwitcher !== false && (_jsx("div", { className: "shrink-0 px-3 pb-1", children: _jsx(ConnectWorkspaceSwitcher, {}) })), _jsx(ConnectSidebarBody, { children: _jsx(ConnectSidebarNav, { nav: visibleNav }) }), (visibleSecondaryNav || sidebarFooter || showAppsGrid) && (_jsxs(ConnectSidebarFooter, { separated: false, children: [visibleSecondaryNav && _jsx(ConnectSidebarNav, { nav: visibleSecondaryNav }), sidebarFooter, showAppsGrid && _jsx(ConnectAppsGrid, { variant: "row" })] })), accountRow && _jsx(ConnectSidebarFooter, { children: accountRow })] }));
|
|
197
|
+
const railContent = (_jsxs(_Fragment, { children: [_jsx(ConnectSidebarHeader, { name: name, icon: icon, homeHref: homeHref }), workspaceSwitcher !== false && (_jsx("div", { className: "shrink-0 px-3 pb-1", children: _jsx(ConnectWorkspaceSwitcher, { alwaysShow: workspaceSwitcher?.alwaysShow ?? false }) })), _jsx(ConnectSidebarBody, { children: _jsx(ConnectSidebarNav, { nav: visibleNav }) }), (visibleSecondaryNav || sidebarFooter || showAppsGrid) && (_jsxs(ConnectSidebarFooter, { separated: false, children: [visibleSecondaryNav && _jsx(ConnectSidebarNav, { nav: visibleSecondaryNav }), sidebarFooter, showAppsGrid && _jsx(ConnectAppsGrid, { variant: "row" })] })), accountRow && _jsx(ConnectSidebarFooter, { children: accountRow })] }));
|
|
198
198
|
return (_jsx(MotionProvider, { children: _jsxs("div", { className: cn("flex h-dvh bg-background", className), children: [_jsx("a", { href: `#${CONTENT_ID}`, className: "sr-only focus:not-sr-only focus:absolute focus:start-3 focus:top-3 focus:z-50 focus:rounded-lg focus:bg-surface focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-lg focus:outline-2 focus:outline-offset-2 focus:outline-focus", children: "Skip to content" }), _jsx(ConnectShellProvider, { value: railShell, children: _jsx(ConnectSidebar, { children: railContent }) }), _jsx(Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: _jsx(Drawer.Content, { placement: "left", className: "w-[min(20rem,85vw)]", children: _jsxs(Drawer.Dialog, { "aria-label": "Navigation menu", className: "p-0 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]", children: [_jsx(Drawer.CloseTrigger, { className: "end-3 top-[calc(--spacing(3)+env(safe-area-inset-top))]" }), _jsx(ConnectShellProvider, { value: drawerShell, children: _jsx("div", { className: "flex h-full flex-col [&>:first-child]:pe-10", children: railContent }) })] }) }) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border bg-surface px-3 md:hidden", children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: _jsx(Menu, { className: "size-5" }) }), _jsx(ConnectLogo, { name: name, icon: icon })] }), _jsx("main", { id: CONTENT_ID, tabIndex: -1, className: "flex-1 overflow-y-auto outline-none", children: _jsxs("div", { className: contentClassName ?? "w-full px-4 py-5 md:px-6 md:py-6", children: [gated ? _jsx(ConnectAccessGate, { children: children }) : children, footer] }) })] }), askAi && _jsx(ConnectAskAi, { ...(askAi === true ? {} : askAi) })] }) }));
|
|
199
199
|
}
|
|
@@ -126,20 +126,25 @@ export function groupPlansByTier(plans) {
|
|
|
126
126
|
description: plan.description,
|
|
127
127
|
monthly: null,
|
|
128
128
|
annual: null,
|
|
129
|
+
oneTime: null,
|
|
129
130
|
annualSavingPercent: null,
|
|
130
131
|
};
|
|
131
132
|
if (plan.billingInterval === "monthly") {
|
|
132
133
|
if (!tier.monthly || plan.price < tier.monthly.price)
|
|
133
134
|
tier.monthly = plan;
|
|
134
135
|
}
|
|
135
|
-
else if (
|
|
136
|
-
tier.annual
|
|
136
|
+
else if (plan.billingInterval === "annual") {
|
|
137
|
+
if (!tier.annual || plan.price < tier.annual.price)
|
|
138
|
+
tier.annual = plan;
|
|
139
|
+
}
|
|
140
|
+
else if (!tier.oneTime || plan.price < tier.oneTime.price) {
|
|
141
|
+
tier.oneTime = plan;
|
|
137
142
|
}
|
|
138
143
|
tiers.set(planGroup, tier);
|
|
139
144
|
}
|
|
140
145
|
return [...tiers.values()]
|
|
141
146
|
.map((tier) => {
|
|
142
|
-
const identity = tier.monthly ?? tier.annual;
|
|
147
|
+
const identity = tier.monthly ?? tier.annual ?? tier.oneTime;
|
|
143
148
|
return {
|
|
144
149
|
...tier,
|
|
145
150
|
name: identity?.name ?? tier.name,
|
|
@@ -150,7 +155,7 @@ export function groupPlansByTier(plans) {
|
|
|
150
155
|
.sort((a, b) => tierFloorPrice(a) - tierFloorPrice(b));
|
|
151
156
|
}
|
|
152
157
|
function tierFloorPrice(tier) {
|
|
153
|
-
const prices = [tier.monthly, tier.annual]
|
|
158
|
+
const prices = [tier.monthly, tier.annual, tier.oneTime]
|
|
154
159
|
.filter((plan) => plan !== null)
|
|
155
160
|
.map((plan) => monthlyOf(plan.price, plan.billingInterval));
|
|
156
161
|
return prices.length > 0 ? Math.min(...prices) : Number.MAX_SAFE_INTEGER;
|
|
@@ -126,7 +126,9 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
|
|
|
126
126
|
// Growth tier, and marking only the row it literally subscribes to
|
|
127
127
|
// would leave its own card unmarked half the time.
|
|
128
128
|
const isCurrentTier = subscription?.plan != null &&
|
|
129
|
-
(tier.monthly?.id === subscription.plan.id ||
|
|
129
|
+
(tier.monthly?.id === subscription.plan.id ||
|
|
130
|
+
tier.annual?.id === subscription.plan.id ||
|
|
131
|
+
tier.oneTime?.id === subscription.plan.id);
|
|
130
132
|
const isCurrent = action === "current";
|
|
131
133
|
const cadenceUnavailable = showAnnual ? tier.annual === null : tier.monthly === null;
|
|
132
134
|
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) => (
|
|
@@ -211,7 +213,13 @@ function ConnectUnsubscribeRow({ subscription, appUrl, checkout, }) {
|
|
|
211
213
|
* monthly-only is a real thing (a free or entry plan usually is), and hiding
|
|
212
214
|
* it while a customer looks at annual pricing loses them an option that is
|
|
213
215
|
* still available to buy.
|
|
216
|
+
*
|
|
217
|
+
* A tier that is *only* a one-time plan ignores the toggle entirely - there
|
|
218
|
+
* is no cadence to switch between, so the price it shows must not depend on
|
|
219
|
+
* `showAnnual`.
|
|
214
220
|
*/
|
|
215
221
|
function pickTierPlan(tier, showAnnual) {
|
|
222
|
+
if (tier.oneTime && !tier.monthly && !tier.annual)
|
|
223
|
+
return tier.oneTime;
|
|
216
224
|
return showAnnual ? (tier.annual ?? tier.monthly) : (tier.monthly ?? tier.annual);
|
|
217
225
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
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 { useState } from "react";
|
|
4
|
-
import { Button, Dropdown, ListBox } from "@heroui/react";
|
|
5
|
-
import { ChevronsUpDown } from "lucide-react";
|
|
4
|
+
import { Button, Dropdown, ListBox, Separator } from "@heroui/react";
|
|
5
|
+
import { ChevronsUpDown, Plus } from "lucide-react";
|
|
6
6
|
import { useConnect, useConnectUser, useConnectWorkspaces } from "../provider.js";
|
|
7
7
|
import { m } from "./motion.js";
|
|
8
8
|
import { SidebarLabel, sidebarRowClass, useConnectShell } from "./shell-context.js";
|
|
@@ -11,6 +11,9 @@ function workspaceInitials(name) {
|
|
|
11
11
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
12
12
|
return (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
|
|
13
13
|
}
|
|
14
|
+
function openExternal(url) {
|
|
15
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
16
|
+
}
|
|
14
17
|
/**
|
|
15
18
|
* Switches which workspace this application is looking at.
|
|
16
19
|
*
|
|
@@ -19,11 +22,15 @@ function workspaceInitials(name) {
|
|
|
19
22
|
* it reads as a heading for the nav rather than as one more destination.
|
|
20
23
|
*
|
|
21
24
|
* It decides for itself whether to appear: an account with one workspace is
|
|
22
|
-
* shown nothing, because a switcher offering one option teaches
|
|
23
|
-
* nothing and costs a row of chrome on every screen. That is the
|
|
24
|
-
* Connect's own switcher, which always renders - Connect is where
|
|
25
|
-
* is *created*, so hiding it at one workspace would guarantee
|
|
26
|
-
* reached two.
|
|
25
|
+
* shown nothing by default, because a switcher offering one option teaches
|
|
26
|
+
* the user nothing and costs a row of chrome on every screen. That is the
|
|
27
|
+
* opposite of Connect's own switcher, which always renders - Connect is where
|
|
28
|
+
* a workspace is *created*, so hiding it at one workspace would guarantee
|
|
29
|
+
* nobody ever reached two. `alwaysShow` is the escape hatch for a partner
|
|
30
|
+
* application that wants that same discoverability without becoming Connect:
|
|
31
|
+
* it keeps the dropdown open below two workspaces and adds a row that sends
|
|
32
|
+
* the member to Connect to actually create one, since there is nothing here
|
|
33
|
+
* that can.
|
|
27
34
|
*
|
|
28
35
|
* **Authorization is the data, not a prop.** The list comes from the caller's
|
|
29
36
|
* own session (`GET /workspaces` takes no user id), so a workspace the user
|
|
@@ -42,19 +49,22 @@ function workspaceInitials(name) {
|
|
|
42
49
|
* component - so the only thing to do afterwards is reload, which is what
|
|
43
50
|
* `onSwitched` defaults to.
|
|
44
51
|
*/
|
|
45
|
-
export function ConnectWorkspaceSwitcher({ onSwitched, row = true, className, }) {
|
|
46
|
-
const { mutateResource } = useConnect();
|
|
52
|
+
export function ConnectWorkspaceSwitcher({ onSwitched, row = true, className, alwaysShow = false, }) {
|
|
53
|
+
const { mutateResource, appUrl } = useConnect();
|
|
47
54
|
const { collapsed } = useConnectShell();
|
|
48
55
|
const user = useConnectUser();
|
|
49
56
|
const { data, isLoading } = useConnectWorkspaces();
|
|
50
57
|
const [switching, setSwitching] = useState(null);
|
|
51
58
|
const [error, setError] = useState(null);
|
|
52
59
|
const workspaces = data?.workspaces ?? [];
|
|
53
|
-
// Nothing
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
60
|
+
// Nothing loaded yet. Deliberately renders `null` rather than a skeleton:
|
|
61
|
+
// this sits in persistent chrome, and a placeholder that resolves to
|
|
62
|
+
// nothing for most accounts is worse than appearing a moment late.
|
|
63
|
+
//
|
|
64
|
+
// Below one workspace, `alwaysShow` is the only thing that keeps this
|
|
65
|
+
// rendering - the default stays "nothing to switch between" for every
|
|
66
|
+
// host that hasn't opted in.
|
|
67
|
+
if (isLoading || (!alwaysShow && workspaces.length < 2))
|
|
58
68
|
return null;
|
|
59
69
|
const active = workspaces.find((workspace) => workspace.isActive);
|
|
60
70
|
const label = active?.name ?? user?.workspaceName ?? "Workspace";
|
|
@@ -84,7 +94,7 @@ export function ConnectWorkspaceSwitcher({ onSwitched, row = true, className, })
|
|
|
84
94
|
// A 32px tile rather than a 20px glyph, so the initials land on the
|
|
85
95
|
// same optical centre the avatar below them uses - see
|
|
86
96
|
// `SIDEBAR_ROW_TILE`. Collapsed, this tile *is* the switcher.
|
|
87
|
-
_jsxs(Button, { variant: "tertiary", "aria-label": `Workspace: ${label}. Switch workspace`, className: sidebarRowClass({ tile: true, className }), children: [_jsx("span", { className: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-xs font-semibold text-accent", children: workspaceInitials(label) }), _jsx(SidebarLabel, { className: "text-sm font-medium text-foreground", children: label }), _jsx(m.span, { className: "shrink-0 text-muted", animate: { opacity: collapsed ? 0 : 1 }, transition: collapsed ? { duration: 0.1 } : { duration: 0.18, delay: 0.1 }, "aria-hidden": true, children: _jsx(ChevronsUpDown, { className: "size-4" }) })] })) : (_jsxs(Button, { variant: "secondary", size: "sm", className: "w-full justify-between", children: [_jsx("span", { className: "truncate", children: label }), _jsx(ChevronsUpDown, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" })] })), _jsx(Dropdown.Popover, { children:
|
|
97
|
+
_jsxs(Button, { variant: "tertiary", "aria-label": `Workspace: ${label}. Switch workspace`, className: sidebarRowClass({ tile: true, className }), children: [_jsx("span", { className: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-xs font-semibold text-accent", children: workspaceInitials(label) }), _jsx(SidebarLabel, { className: "text-sm font-medium text-foreground", children: label }), _jsx(m.span, { className: "shrink-0 text-muted", animate: { opacity: collapsed ? 0 : 1 }, transition: collapsed ? { duration: 0.1 } : { duration: 0.18, delay: 0.1 }, "aria-hidden": true, children: _jsx(ChevronsUpDown, { className: "size-4" }) })] })) : (_jsxs(Button, { variant: "secondary", size: "sm", className: "w-full justify-between", children: [_jsx("span", { className: "truncate", children: label }), _jsx(ChevronsUpDown, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" })] })), _jsx(Dropdown.Popover, { children: _jsxs(ListBox, { children: [workspaces.map((workspace) => (_jsxs(ListBox.Item, { id: workspace.workspaceId, textValue: workspace.name, isDisabled: !selectable(workspace) || switching !== null, onAction: () => void handleSwitch(workspace), children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-sm", children: workspace.name }), selectable(workspace) ? null : (_jsx("p", { className: "truncate text-xs text-muted", children: reasonUnavailable(workspace) }))] }), _jsx(ListBox.ItemIndicator, {})] }, workspace.workspaceId))), alwaysShow ? (_jsxs(_Fragment, { children: [workspaces.length > 0 && _jsx(Separator, {}), _jsxs(ListBox.Item, { id: "__create_workspace", textValue: "Create workspace", onAction: () => openExternal(appUrl), children: [_jsx(Plus, { className: "size-4 shrink-0 text-muted", "aria-hidden": true }), _jsx("p", { className: "truncate text-sm", children: "Create workspace" })] })] })) : null] }) })] }), error && !collapsed ? _jsx("p", { className: "mt-1 px-2 text-xs text-danger", children: error }) : null] }));
|
|
88
98
|
}
|
|
89
99
|
/** Belongs to the user, and usable here. The same two facts `switch` enforces. */
|
|
90
100
|
function selectable(workspace) {
|
package/dist/esm/next/index.js
CHANGED
|
@@ -321,6 +321,45 @@ async function readSession(request, options) {
|
|
|
321
321
|
const cachedAmbientSession = cache(async () => {
|
|
322
322
|
return readSession(await requestFromHeaders(), {});
|
|
323
323
|
});
|
|
324
|
+
/**
|
|
325
|
+
* The caller's opaque Connect **session token**, or `null` when signed out.
|
|
326
|
+
*
|
|
327
|
+
* ## What it is for
|
|
328
|
+
*
|
|
329
|
+
* Every `asUser` call on the server client needs one - `workspaces.*`,
|
|
330
|
+
* `scheduling.*`, the `/data` store's per-user form - and until this existed
|
|
331
|
+
* there was no way to obtain it inside a Server Component. `getConnectSession`
|
|
332
|
+
* answers *who* the caller is and deliberately does not hand back the
|
|
333
|
+
* credential, so a page that wanted to read its own workspace's roster had to
|
|
334
|
+
* either reimplement the cookie read or route the call through the browser.
|
|
335
|
+
*
|
|
336
|
+
* ```tsx
|
|
337
|
+
* // app/settings/page.tsx
|
|
338
|
+
* const token = await getConnectSessionToken();
|
|
339
|
+
* const members = token ? await connect.workspaces.members({ asUser: token }) : [];
|
|
340
|
+
* ```
|
|
341
|
+
*
|
|
342
|
+
* ## It is the cookie, unverified
|
|
343
|
+
*
|
|
344
|
+
* This reads the session store and nothing else - **no round trip, and no
|
|
345
|
+
* check that the token is still live.** A token from a revoked or expired
|
|
346
|
+
* session is a string that looks exactly like a good one, and Connect is what
|
|
347
|
+
* answers `401` for it. So this is not a sign-in check: use
|
|
348
|
+
* `getConnectSession()` for "is somebody here", and this for "hand Connect the
|
|
349
|
+
* credential they arrived with". A non-null answer means a cookie was present,
|
|
350
|
+
* not that it works.
|
|
351
|
+
*
|
|
352
|
+
* Reads `cookies()` transitively, so under `cacheComponents` it belongs inside
|
|
353
|
+
* a `<Suspense>` boundary like any other request-scoped read.
|
|
354
|
+
*
|
|
355
|
+
* Pass a `Request` to use it where `next/headers` does not exist, and a
|
|
356
|
+
* `session` store if you did not use the default cookie - the same two escape
|
|
357
|
+
* hatches `getConnectSession` takes, for the same reasons.
|
|
358
|
+
*/
|
|
359
|
+
export async function getConnectSessionToken(request, options = {}) {
|
|
360
|
+
const store = options.session ?? cookieSessionStore();
|
|
361
|
+
return (await store.read(request ?? (await requestFromHeaders()))) ?? null;
|
|
362
|
+
}
|
|
324
363
|
/**
|
|
325
364
|
* The signed-in user for a Server Component, Route Handler or Server Action,
|
|
326
365
|
* or `null`.
|
|
@@ -51,6 +51,39 @@ export class WorkspacesResource {
|
|
|
51
51
|
json: input,
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The billing identity of the workspace this session is on - its legal
|
|
56
|
+
* name, GSTIN, registered address and entity form.
|
|
57
|
+
*
|
|
58
|
+
* **For prefilling your own settings, once.** See
|
|
59
|
+
* `WorkspaceBillingProfile`, which carries the whole argument: this is the
|
|
60
|
+
* *Bill To* side, yours is the *Bill From* side, and they are the same facts
|
|
61
|
+
* for most customers without being the same field. There is no write form.
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* // app/settings/page.tsx - offer "Use my billing details"
|
|
65
|
+
* const token = await getConnectSessionToken();
|
|
66
|
+
* const billing = token ? await connect.workspaces.billingProfile({ asUser: token }) : null;
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* **Needs two things, not one.** `workspaces:read` on your key, *and*
|
|
70
|
+
* `billing:read` on the signed-in member - a tax identity belongs to
|
|
71
|
+
* whoever can already see it in Connect's billing screens, so an ordinary
|
|
72
|
+
* member gets a 403 here where they get a roster from `members()`. That
|
|
73
|
+
* cannot be granted through a role on your application, deliberately.
|
|
74
|
+
*
|
|
75
|
+
* So **treat the 403 as a normal branch**, not an error: offer the prefill
|
|
76
|
+
* to whoever can use it and let everyone else type their own details. A
|
|
77
|
+
* workspace admin is the person who sets this up anyway.
|
|
78
|
+
*/
|
|
79
|
+
async billingProfile(options) {
|
|
80
|
+
const result = await request(this.transport, {
|
|
81
|
+
method: "GET",
|
|
82
|
+
path: "/workspaces/billing-profile",
|
|
83
|
+
headers: actingAs(options.asUser),
|
|
84
|
+
});
|
|
85
|
+
return result.billingProfile;
|
|
86
|
+
}
|
|
54
87
|
/**
|
|
55
88
|
* The team of the workspace this session is on.
|
|
56
89
|
*
|
|
@@ -36,8 +36,14 @@ export interface AppShellProps {
|
|
|
36
36
|
* for an account with only one workspace - so the reason to pass `false` is
|
|
37
37
|
* not "most users have one" (already handled) but "this application renders
|
|
38
38
|
* its own".
|
|
39
|
+
*
|
|
40
|
+
* Pass `{ alwaysShow: true }` instead of `true` to keep it mounted below
|
|
41
|
+
* two workspaces too - see `ConnectWorkspaceSwitcherProps.alwaysShow` for
|
|
42
|
+
* what that changes about the dropdown.
|
|
39
43
|
*/
|
|
40
|
-
workspaceSwitcher?: false
|
|
44
|
+
workspaceSwitcher?: false | {
|
|
45
|
+
alwaysShow?: boolean;
|
|
46
|
+
};
|
|
41
47
|
/** Rendered below `children`, inside the scrolling content pane. */
|
|
42
48
|
footer?: ReactNode;
|
|
43
49
|
/**
|
|
@@ -74,6 +74,16 @@ export interface PlanTier {
|
|
|
74
74
|
description: string;
|
|
75
75
|
monthly: Plan | null;
|
|
76
76
|
annual: Plan | null;
|
|
77
|
+
/**
|
|
78
|
+
* A plan paid once, never on a cadence - its own slot rather than falling
|
|
79
|
+
* into `annual`. A one-time plan has no renewal to discount against, so
|
|
80
|
+
* filing it as "annual" used to let a tier mixing a recurring plan with a
|
|
81
|
+
* one-time one compute a `annualSavingPercent` between a monthly charge and
|
|
82
|
+
* a lump sum, and offered the one-time purchase under the annual toggle as
|
|
83
|
+
* though it renewed. `pickTierPlan` in `plans.tsx` is the only reader that
|
|
84
|
+
* needs to know this slot exists.
|
|
85
|
+
*/
|
|
86
|
+
oneTime: Plan | null;
|
|
77
87
|
/** Whole-percent saving from paying annually, or `null` when this tier has no pair to compare. */
|
|
78
88
|
annualSavingPercent: number | null;
|
|
79
89
|
}
|
|
@@ -14,6 +14,21 @@ export interface ConnectWorkspaceSwitcherProps {
|
|
|
14
14
|
*/
|
|
15
15
|
row?: boolean;
|
|
16
16
|
className?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Render even when the signed-in account has fewer than two workspaces.
|
|
19
|
+
*
|
|
20
|
+
* Off by default, which keeps this component's documented posture for
|
|
21
|
+
* every other host: an account with one workspace sees nothing, because a
|
|
22
|
+
* switcher offering one option teaches it nothing. Pass `true` on an
|
|
23
|
+
* application that wants a single-workspace account to be able to
|
|
24
|
+
* discover - and start - creating a second one without leaving its own
|
|
25
|
+
* chrome. There is deliberately no `workspaces.create()` on the SDK:
|
|
26
|
+
* creating a workspace is Connect's job, tied to KYC/KYB and a billing
|
|
27
|
+
* profile, not something an API key should be able to trigger - so turning
|
|
28
|
+
* this on also adds a "Create workspace" row that opens Connect, rather
|
|
29
|
+
* than pretending the switcher can do it itself.
|
|
30
|
+
*/
|
|
31
|
+
alwaysShow?: boolean;
|
|
17
32
|
}
|
|
18
33
|
/**
|
|
19
34
|
* Switches which workspace this application is looking at.
|
|
@@ -23,11 +38,15 @@ export interface ConnectWorkspaceSwitcherProps {
|
|
|
23
38
|
* it reads as a heading for the nav rather than as one more destination.
|
|
24
39
|
*
|
|
25
40
|
* It decides for itself whether to appear: an account with one workspace is
|
|
26
|
-
* shown nothing, because a switcher offering one option teaches
|
|
27
|
-
* nothing and costs a row of chrome on every screen. That is the
|
|
28
|
-
* Connect's own switcher, which always renders - Connect is where
|
|
29
|
-
* is *created*, so hiding it at one workspace would guarantee
|
|
30
|
-
* reached two.
|
|
41
|
+
* shown nothing by default, because a switcher offering one option teaches
|
|
42
|
+
* the user nothing and costs a row of chrome on every screen. That is the
|
|
43
|
+
* opposite of Connect's own switcher, which always renders - Connect is where
|
|
44
|
+
* a workspace is *created*, so hiding it at one workspace would guarantee
|
|
45
|
+
* nobody ever reached two. `alwaysShow` is the escape hatch for a partner
|
|
46
|
+
* application that wants that same discoverability without becoming Connect:
|
|
47
|
+
* it keeps the dropdown open below two workspaces and adds a row that sends
|
|
48
|
+
* the member to Connect to actually create one, since there is nothing here
|
|
49
|
+
* that can.
|
|
31
50
|
*
|
|
32
51
|
* **Authorization is the data, not a prop.** The list comes from the caller's
|
|
33
52
|
* own session (`GET /workspaces` takes no user id), so a workspace the user
|
|
@@ -46,4 +65,4 @@ export interface ConnectWorkspaceSwitcherProps {
|
|
|
46
65
|
* component - so the only thing to do afterwards is reload, which is what
|
|
47
66
|
* `onSwitched` defaults to.
|
|
48
67
|
*/
|
|
49
|
-
export declare function ConnectWorkspaceSwitcher({ onSwitched, row, className, }: Readonly<ConnectWorkspaceSwitcherProps>): import("react").JSX.Element | null;
|
|
68
|
+
export declare function ConnectWorkspaceSwitcher({ onSwitched, row, className, alwaysShow, }: Readonly<ConnectWorkspaceSwitcherProps>): import("react").JSX.Element | null;
|
|
@@ -120,6 +120,44 @@ declare function handle(request: Request): Promise<Response>;
|
|
|
120
120
|
* when you need to configure one of them.
|
|
121
121
|
*/
|
|
122
122
|
export { handle as GET, handle as POST };
|
|
123
|
+
/**
|
|
124
|
+
* The caller's opaque Connect **session token**, or `null` when signed out.
|
|
125
|
+
*
|
|
126
|
+
* ## What it is for
|
|
127
|
+
*
|
|
128
|
+
* Every `asUser` call on the server client needs one - `workspaces.*`,
|
|
129
|
+
* `scheduling.*`, the `/data` store's per-user form - and until this existed
|
|
130
|
+
* there was no way to obtain it inside a Server Component. `getConnectSession`
|
|
131
|
+
* answers *who* the caller is and deliberately does not hand back the
|
|
132
|
+
* credential, so a page that wanted to read its own workspace's roster had to
|
|
133
|
+
* either reimplement the cookie read or route the call through the browser.
|
|
134
|
+
*
|
|
135
|
+
* ```tsx
|
|
136
|
+
* // app/settings/page.tsx
|
|
137
|
+
* const token = await getConnectSessionToken();
|
|
138
|
+
* const members = token ? await connect.workspaces.members({ asUser: token }) : [];
|
|
139
|
+
* ```
|
|
140
|
+
*
|
|
141
|
+
* ## It is the cookie, unverified
|
|
142
|
+
*
|
|
143
|
+
* This reads the session store and nothing else - **no round trip, and no
|
|
144
|
+
* check that the token is still live.** A token from a revoked or expired
|
|
145
|
+
* session is a string that looks exactly like a good one, and Connect is what
|
|
146
|
+
* answers `401` for it. So this is not a sign-in check: use
|
|
147
|
+
* `getConnectSession()` for "is somebody here", and this for "hand Connect the
|
|
148
|
+
* credential they arrived with". A non-null answer means a cookie was present,
|
|
149
|
+
* not that it works.
|
|
150
|
+
*
|
|
151
|
+
* Reads `cookies()` transitively, so under `cacheComponents` it belongs inside
|
|
152
|
+
* a `<Suspense>` boundary like any other request-scoped read.
|
|
153
|
+
*
|
|
154
|
+
* Pass a `Request` to use it where `next/headers` does not exist, and a
|
|
155
|
+
* `session` store if you did not use the default cookie - the same two escape
|
|
156
|
+
* hatches `getConnectSession` takes, for the same reasons.
|
|
157
|
+
*/
|
|
158
|
+
export declare function getConnectSessionToken(request?: Request, options?: {
|
|
159
|
+
session?: ConnectSessionStore;
|
|
160
|
+
}): Promise<string | null>;
|
|
123
161
|
/**
|
|
124
162
|
* The signed-in user for a Server Component, Route Handler or Server Action,
|
|
125
163
|
* or `null`.
|
|
@@ -42,7 +42,12 @@ export interface RescheduleBookingInput extends ActingAsOption {
|
|
|
42
42
|
export interface ListBookingsInput extends ActingAsOption {
|
|
43
43
|
workspaceId: string;
|
|
44
44
|
status?: BookingStatus;
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* A `ConnectWorkspaceMember.memberId` - only bookings that committed them.
|
|
47
|
+
*
|
|
48
|
+
* **Not a `userId`.** Pass one and the filter matches nothing, which comes
|
|
49
|
+
* back as an empty diary rather than an error.
|
|
50
|
+
*/
|
|
46
51
|
hostMemberId?: string;
|
|
47
52
|
from?: string | Date;
|
|
48
53
|
to?: string | Date;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type TransportOptions } from "../http.js";
|
|
2
|
-
import type { ConnectInvitation, ConnectMemberAccess, ConnectMemberAccessResult, ConnectWorkspaceMember, UserWorkspace, WorkspaceSwitchResult } from "../../types.js";
|
|
2
|
+
import type { ConnectInvitation, ConnectMemberAccess, ConnectMemberAccessResult, ConnectWorkspaceMember, UserWorkspace, WorkspaceBillingProfile, WorkspaceSwitchResult } from "../../types.js";
|
|
3
3
|
/**
|
|
4
4
|
* Which workspaces the signed-in user belongs to, moving between them, who
|
|
5
5
|
* else is on the team, and inviting or granting access to your application.
|
|
@@ -41,6 +41,34 @@ export declare class WorkspacesResource {
|
|
|
41
41
|
}, options: {
|
|
42
42
|
asUser: string;
|
|
43
43
|
}): Promise<WorkspaceSwitchResult>;
|
|
44
|
+
/**
|
|
45
|
+
* The billing identity of the workspace this session is on - its legal
|
|
46
|
+
* name, GSTIN, registered address and entity form.
|
|
47
|
+
*
|
|
48
|
+
* **For prefilling your own settings, once.** See
|
|
49
|
+
* `WorkspaceBillingProfile`, which carries the whole argument: this is the
|
|
50
|
+
* *Bill To* side, yours is the *Bill From* side, and they are the same facts
|
|
51
|
+
* for most customers without being the same field. There is no write form.
|
|
52
|
+
*
|
|
53
|
+
* ```ts
|
|
54
|
+
* // app/settings/page.tsx - offer "Use my billing details"
|
|
55
|
+
* const token = await getConnectSessionToken();
|
|
56
|
+
* const billing = token ? await connect.workspaces.billingProfile({ asUser: token }) : null;
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* **Needs two things, not one.** `workspaces:read` on your key, *and*
|
|
60
|
+
* `billing:read` on the signed-in member - a tax identity belongs to
|
|
61
|
+
* whoever can already see it in Connect's billing screens, so an ordinary
|
|
62
|
+
* member gets a 403 here where they get a roster from `members()`. That
|
|
63
|
+
* cannot be granted through a role on your application, deliberately.
|
|
64
|
+
*
|
|
65
|
+
* So **treat the 403 as a normal branch**, not an error: offer the prefill
|
|
66
|
+
* to whoever can use it and let everyone else type their own details. A
|
|
67
|
+
* workspace admin is the person who sets this up anyway.
|
|
68
|
+
*/
|
|
69
|
+
billingProfile(options: {
|
|
70
|
+
asUser: string;
|
|
71
|
+
}): Promise<WorkspaceBillingProfile>;
|
|
44
72
|
/**
|
|
45
73
|
* The team of the workspace this session is on.
|
|
46
74
|
*
|
package/dist/types/types.d.ts
CHANGED
|
@@ -1741,6 +1741,59 @@ export interface UserWorkspace {
|
|
|
1741
1741
|
/** Whether it holds an entitling subscription to your application. */
|
|
1742
1742
|
subscribed: boolean;
|
|
1743
1743
|
}
|
|
1744
|
+
/**
|
|
1745
|
+
* A workspace's registered address, as its billing profile reports it.
|
|
1746
|
+
*
|
|
1747
|
+
* `stateCode` is the Indian state code, and it is the field that matters most
|
|
1748
|
+
* of the six: it decides CGST+SGST versus IGST on anything you raise against
|
|
1749
|
+
* this entity.
|
|
1750
|
+
*/
|
|
1751
|
+
export interface WorkspaceBillingAddress {
|
|
1752
|
+
line1: string | null;
|
|
1753
|
+
line2: string | null;
|
|
1754
|
+
city: string | null;
|
|
1755
|
+
stateCode: string | null;
|
|
1756
|
+
postalCode: string | null;
|
|
1757
|
+
countryCode: string;
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* A workspace's **self-declared** billing identity - what the customer gave
|
|
1761
|
+
* CosmoOps so we can invoice them.
|
|
1762
|
+
*
|
|
1763
|
+
* ## Read it to prefill, not to share
|
|
1764
|
+
*
|
|
1765
|
+
* This is the *Bill To* side: who we invoice for the subscription. Your own
|
|
1766
|
+
* application's letterhead is the *Bill From* side. For a single-entity
|
|
1767
|
+
* customer they are the same three facts, which is the whole reason this is
|
|
1768
|
+
* readable - a GSTIN typed twice is a GSTIN that diverges, and the copy on a
|
|
1769
|
+
* quotation is the one that decides how the tax splits.
|
|
1770
|
+
*
|
|
1771
|
+
* They are **not the same field**, though. A group that bills us through a
|
|
1772
|
+
* holding company while quoting under a trading subsidiary has to stay
|
|
1773
|
+
* expressible, so copy this into your own settings on first setup and keep
|
|
1774
|
+
* your copy authoritative. There is deliberately no write form: an
|
|
1775
|
+
* application must not be able to edit the details its own invoices are
|
|
1776
|
+
* raised against.
|
|
1777
|
+
*
|
|
1778
|
+
* ## Nothing here is verified
|
|
1779
|
+
*
|
|
1780
|
+
* It is what the customer typed, not a KYB record - no PAN check stands
|
|
1781
|
+
* behind it. A convenience, never evidence of identity.
|
|
1782
|
+
*
|
|
1783
|
+
* Every field is nullable, because every one is optional to a customer who has
|
|
1784
|
+
* not needed an invoice yet. Fall back to the workspace name you already hold
|
|
1785
|
+
* on the session rather than printing an empty letterhead.
|
|
1786
|
+
*/
|
|
1787
|
+
export interface WorkspaceBillingProfile {
|
|
1788
|
+
/** The registered entity name, or `null` - most workspaces never set one. */
|
|
1789
|
+
legalName: string | null;
|
|
1790
|
+
/** GSTIN, 15 characters. `null` for an unregistered customer. */
|
|
1791
|
+
taxId: string | null;
|
|
1792
|
+
/** `null` as a whole when none was given - one check, not six. */
|
|
1793
|
+
billingAddress: WorkspaceBillingAddress | null;
|
|
1794
|
+
/** The legal entity form, self-declared. `null` for an individual workspace. */
|
|
1795
|
+
businessType: string | null;
|
|
1796
|
+
}
|
|
1744
1797
|
/**
|
|
1745
1798
|
* The result of a workspace switch.
|
|
1746
1799
|
*
|
|
@@ -1761,6 +1814,20 @@ export interface WorkspaceSwitchResult {
|
|
|
1761
1814
|
* catalog, not the member's Connect roles.
|
|
1762
1815
|
*/
|
|
1763
1816
|
export interface ConnectWorkspaceMember {
|
|
1817
|
+
/**
|
|
1818
|
+
* The id the **calendar** speaks in - `Booking.hostMemberIds` holds these,
|
|
1819
|
+
* and so does `listBookings`' `hostMemberId` filter.
|
|
1820
|
+
*
|
|
1821
|
+
* Carried beside `userId` because the two are different identifiers and
|
|
1822
|
+
* scheduling is the one surface that uses this one: joining a diary to a
|
|
1823
|
+
* roster, filtering it to one host, or putting a name against an
|
|
1824
|
+
* appointment all go through `memberId`. Everywhere else a member is a
|
|
1825
|
+
* `userId`.
|
|
1826
|
+
*
|
|
1827
|
+
* Opaque and workspace-scoped. Do not derive it from `userId` or address a
|
|
1828
|
+
* member by it elsewhere in this API.
|
|
1829
|
+
*/
|
|
1830
|
+
memberId: string;
|
|
1764
1831
|
userId: string;
|
|
1765
1832
|
email: string;
|
|
1766
1833
|
displayName: string | null;
|
|
@@ -1885,9 +1952,15 @@ export interface Booking {
|
|
|
1885
1952
|
/**
|
|
1886
1953
|
* The workspace members committed to this appointment.
|
|
1887
1954
|
*
|
|
1888
|
-
* These are `
|
|
1889
|
-
* returns and `listBookings`'
|
|
1890
|
-
*
|
|
1955
|
+
* These are `ConnectWorkspaceMember.memberId` values - what
|
|
1956
|
+
* `workspaces.members()` returns as `memberId` and what `listBookings`'
|
|
1957
|
+
* `hostMemberId` filter takes, so a host column is a lookup against a roster
|
|
1958
|
+
* you already have rather than another round trip.
|
|
1959
|
+
*
|
|
1960
|
+
* **Not `userId`.** An earlier version of this comment said these were the
|
|
1961
|
+
* ids `workspaces.listMembers` returns, which was wrong twice over: that
|
|
1962
|
+
* method is `workspaces.members()`, and until `memberId` was added to its
|
|
1963
|
+
* rows it returned no id these could be matched against at all.
|
|
1891
1964
|
*
|
|
1892
1965
|
* Resolved when the slot was held and frozen there: a round-robin event type
|
|
1893
1966
|
* picks one host, a collective one commits the whole group, and re-pointing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmoops/connect-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.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",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"clean": "rm -rf dist",
|
|
80
80
|
"build": "bun run clean && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && tsc -p tsconfig.types.json && bun run scripts/finalize-dist.ts",
|
|
81
81
|
"verify:pack": "bun run scripts/verify-pack.ts",
|
|
82
|
-
"prepublishOnly": "bun run build && bun run verify:pack",
|
|
82
|
+
"prepublishOnly": "bun run build && bun run verify:pack && bun test",
|
|
83
83
|
"test": "bun test",
|
|
84
84
|
"docs:md": "typedoc --options typedoc.markdown.json",
|
|
85
85
|
"storybook": "storybook dev -p 6106",
|