@cosmoops/connect-sdk 0.1.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/LICENSE +34 -0
- package/README.md +446 -0
- package/dist/cjs/cli/bin.js +16 -0
- package/dist/cjs/cli/init.js +179 -0
- package/dist/cjs/client/components/account-sessions.js +112 -0
- package/dist/cjs/client/components/apps-grid.js +46 -0
- package/dist/cjs/client/components/auth-buttons.js +42 -0
- package/dist/cjs/client/components/feature-gate.js +61 -0
- package/dist/cjs/client/components/link-button.js +24 -0
- package/dist/cjs/client/components/logo.js +27 -0
- package/dist/cjs/client/components/plan-pricing.js +166 -0
- package/dist/cjs/client/components/plans-page.js +39 -0
- package/dist/cjs/client/components/plans.js +212 -0
- package/dist/cjs/client/components/readiness.js +117 -0
- package/dist/cjs/client/components/subscribe-dialog.js +60 -0
- package/dist/cjs/client/components/subscription.js +189 -0
- package/dist/cjs/client/components/usage-meter.js +68 -0
- package/dist/cjs/client/components/user-menu.js +172 -0
- package/dist/cjs/client/index.js +99 -0
- package/dist/cjs/client/mark.js +16 -0
- package/dist/cjs/client/provider.js +259 -0
- package/dist/cjs/client/use-checkout.js +173 -0
- package/dist/cjs/errors.js +74 -0
- package/dist/cjs/format-relative-time.js +18 -0
- package/dist/cjs/helpers.js +30 -0
- package/dist/cjs/index.js +22 -0
- package/dist/cjs/next/index.js +311 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/server/client.js +114 -0
- package/dist/cjs/server/handler.js +333 -0
- package/dist/cjs/server/http.js +160 -0
- package/dist/cjs/server/index.js +18 -0
- package/dist/cjs/server/resources/access.js +28 -0
- package/dist/cjs/server/resources/ai.js +36 -0
- package/dist/cjs/server/resources/apps.js +30 -0
- package/dist/cjs/server/resources/billing.js +71 -0
- package/dist/cjs/server/resources/drive.js +39 -0
- package/dist/cjs/server/resources/entitlements.js +49 -0
- package/dist/cjs/server/resources/identity.js +194 -0
- package/dist/cjs/server/resources/logs.js +41 -0
- package/dist/cjs/server/resources/otp.js +29 -0
- package/dist/cjs/server/resources/sessions.js +82 -0
- package/dist/cjs/types.js +15 -0
- package/dist/esm/cli/bin.js +13 -0
- package/dist/esm/cli/init.js +174 -0
- package/dist/esm/client/components/account-sessions.js +109 -0
- package/dist/esm/client/components/apps-grid.js +43 -0
- package/dist/esm/client/components/auth-buttons.js +38 -0
- package/dist/esm/client/components/feature-gate.js +57 -0
- package/dist/esm/client/components/link-button.js +21 -0
- package/dist/esm/client/components/logo.js +24 -0
- package/dist/esm/client/components/plan-pricing.js +156 -0
- package/dist/esm/client/components/plans-page.js +36 -0
- package/dist/esm/client/components/plans.js +209 -0
- package/dist/esm/client/components/readiness.js +113 -0
- package/dist/esm/client/components/subscribe-dialog.js +57 -0
- package/dist/esm/client/components/subscription.js +181 -0
- package/dist/esm/client/components/usage-meter.js +65 -0
- package/dist/esm/client/components/user-menu.js +164 -0
- package/dist/esm/client/index.js +47 -0
- package/dist/esm/client/mark.js +13 -0
- package/dist/esm/client/provider.js +247 -0
- package/dist/esm/client/use-checkout.js +168 -0
- package/dist/esm/errors.js +69 -0
- package/dist/esm/format-relative-time.js +15 -0
- package/dist/esm/helpers.js +26 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/next/index.js +279 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/server/client.js +110 -0
- package/dist/esm/server/handler.js +330 -0
- package/dist/esm/server/http.js +156 -0
- package/dist/esm/server/index.js +10 -0
- package/dist/esm/server/resources/access.js +24 -0
- package/dist/esm/server/resources/ai.js +32 -0
- package/dist/esm/server/resources/apps.js +26 -0
- package/dist/esm/server/resources/billing.js +67 -0
- package/dist/esm/server/resources/drive.js +35 -0
- package/dist/esm/server/resources/entitlements.js +45 -0
- package/dist/esm/server/resources/identity.js +189 -0
- package/dist/esm/server/resources/logs.js +37 -0
- package/dist/esm/server/resources/otp.js +25 -0
- package/dist/esm/server/resources/sessions.js +78 -0
- package/dist/esm/types.js +14 -0
- package/dist/theme.css +84 -0
- package/dist/types/cli/bin.d.ts +1 -0
- package/dist/types/cli/init.d.ts +34 -0
- package/dist/types/client/components/account-sessions.d.ts +25 -0
- package/dist/types/client/components/apps-grid.d.ts +20 -0
- package/dist/types/client/components/auth-buttons.d.ts +31 -0
- package/dist/types/client/components/feature-gate.d.ts +57 -0
- package/dist/types/client/components/link-button.d.ts +20 -0
- package/dist/types/client/components/logo.d.ts +35 -0
- package/dist/types/client/components/plan-pricing.d.ts +96 -0
- package/dist/types/client/components/plans-page.d.ts +36 -0
- package/dist/types/client/components/plans.d.ts +34 -0
- package/dist/types/client/components/readiness.d.ts +52 -0
- package/dist/types/client/components/subscribe-dialog.d.ts +34 -0
- package/dist/types/client/components/subscription.d.ts +123 -0
- package/dist/types/client/components/usage-meter.d.ts +24 -0
- package/dist/types/client/components/user-menu.d.ts +86 -0
- package/dist/types/client/index.d.ts +47 -0
- package/dist/types/client/mark.d.ts +11 -0
- package/dist/types/client/provider.d.ts +209 -0
- package/dist/types/client/use-checkout.d.ts +93 -0
- package/dist/types/errors.d.ts +62 -0
- package/dist/types/format-relative-time.d.ts +2 -0
- package/dist/types/helpers.d.ts +33 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/next/index.d.ts +136 -0
- package/dist/types/server/client.d.ts +83 -0
- package/dist/types/server/handler.d.ts +67 -0
- package/dist/types/server/http.d.ts +52 -0
- package/dist/types/server/index.d.ts +15 -0
- package/dist/types/server/resources/access.d.ts +17 -0
- package/dist/types/server/resources/ai.d.ts +24 -0
- package/dist/types/server/resources/apps.d.ts +12 -0
- package/dist/types/server/resources/billing.d.ts +41 -0
- package/dist/types/server/resources/drive.d.ts +26 -0
- package/dist/types/server/resources/entitlements.d.ts +31 -0
- package/dist/types/server/resources/identity.d.ts +76 -0
- package/dist/types/server/resources/logs.d.ts +20 -0
- package/dist/types/server/resources/otp.d.ts +17 -0
- package/dist/types/server/resources/sessions.d.ts +37 -0
- package/dist/types/types.d.ts +434 -0
- package/package.json +102 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ConnectPlans = ConnectPlans;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("@heroui/react");
|
|
7
|
+
const lucide_react_1 = require("lucide-react");
|
|
8
|
+
const react_2 = require("react");
|
|
9
|
+
const provider_js_1 = require("../provider.js");
|
|
10
|
+
const use_checkout_js_1 = require("../use-checkout.js");
|
|
11
|
+
const subscribe_dialog_js_1 = require("./subscribe-dialog.js");
|
|
12
|
+
const plan_pricing_js_1 = require("./plan-pricing.js");
|
|
13
|
+
/**
|
|
14
|
+
* The plan table: monthly/annual toggle, features, add-ons, the discount
|
|
15
|
+
* actually in effect, and the subscribe / upgrade / downgrade flows.
|
|
16
|
+
*
|
|
17
|
+
* ## Where the flow runs, and where it finishes
|
|
18
|
+
*
|
|
19
|
+
* The *flow* is here: picking a plan, seeing what it costs with GST, being
|
|
20
|
+
* told what happens to the cycle you are part-way through, and confirming.
|
|
21
|
+
* The *payment* is not, and that is deliberate. A plan change is not one
|
|
22
|
+
* write - it re-runs the readiness gates, prices proration against
|
|
23
|
+
* accumulated credit, re-checks the RBI mandate ceiling for the new amount,
|
|
24
|
+
* and issues a GST invoice. Connect owns that sequence end to end; a
|
|
25
|
+
* partner-side "change plan" call would be a second implementation of it
|
|
26
|
+
* that drifts. It would also mean a browser holding the partner's
|
|
27
|
+
* credentials could move money, which is the one thing the bridge exists to
|
|
28
|
+
* prevent.
|
|
29
|
+
*
|
|
30
|
+
* So confirming opens Connect - on the *exact* confirmation screen for the
|
|
31
|
+
* plan just picked, via an intercepted parallel route, not a catalogue the
|
|
32
|
+
* customer has to search again - and this component watches `/entitlements`
|
|
33
|
+
* until the change lands, then repaints itself. See `useConnectCheckout`.
|
|
34
|
+
*
|
|
35
|
+
* Coupon codes work the same way: the field carries the code into Connect's
|
|
36
|
+
* checkout, where it is validated and applied against the real cart. Anything
|
|
37
|
+
* this component claimed about a code would be a guess it cannot honour.
|
|
38
|
+
*/
|
|
39
|
+
function ConnectPlans({ planIds, hideCoupon = false, className }) {
|
|
40
|
+
const { appUrl, session } = (0, provider_js_1.useConnect)();
|
|
41
|
+
const { data: catalog, isLoading: plansLoading, error } = (0, provider_js_1.useConnectPlans)();
|
|
42
|
+
const { data: entitlements, isLoading: entitlementsLoading } = (0, provider_js_1.useConnectEntitlements)();
|
|
43
|
+
const checkout = (0, use_checkout_js_1.useConnectCheckout)();
|
|
44
|
+
const [annual, setAnnual] = (0, react_2.useState)(false);
|
|
45
|
+
const [coupon, setCoupon] = (0, react_2.useState)("");
|
|
46
|
+
/** The plan under review in the confirmation dialog, or `null` when it is closed. */
|
|
47
|
+
const [pending, setPending] = (0, react_2.useState)(null);
|
|
48
|
+
const subscription = entitlements?.subscription ?? null;
|
|
49
|
+
const serviceId = subscription?.serviceId ?? null;
|
|
50
|
+
const allPlans = (0, react_2.useMemo)(() => {
|
|
51
|
+
const plans = catalog?.plans ?? [];
|
|
52
|
+
if (!planIds)
|
|
53
|
+
return plans;
|
|
54
|
+
return planIds.map((id) => plans.find((plan) => plan.id === id)).filter((plan) => !!plan);
|
|
55
|
+
}, [catalog?.plans, planIds]);
|
|
56
|
+
// One card per *tier*, with the toggle choosing a price inside each card.
|
|
57
|
+
// The grid used to filter the flat plan list down to the toggled interval,
|
|
58
|
+
// which meant a tier sold monthly-only disappeared the moment a customer
|
|
59
|
+
// looked at annual pricing - and an all-annual catalog rendered nothing at
|
|
60
|
+
// all, since the toggle starts on monthly.
|
|
61
|
+
const tiers = (0, react_2.useMemo)(() => (0, plan_pricing_js_1.groupPlansByTier)(allPlans), [allPlans]);
|
|
62
|
+
const hasAnnual = tiers.some((tier) => tier.annual !== null);
|
|
63
|
+
const hasMonthly = tiers.some((tier) => tier.monthly !== null);
|
|
64
|
+
// Derived, not stored: a catalog with no monthly plans has to open on annual,
|
|
65
|
+
// and deriving it here keeps that correct from the first paint without an
|
|
66
|
+
// effect that would flash the empty state first.
|
|
67
|
+
const showAnnual = hasMonthly ? annual : true;
|
|
68
|
+
// "Up to", because each tier discounts its annual plan by its own amount -
|
|
69
|
+
// the badge used to quote one figure computed from whichever monthly and
|
|
70
|
+
// annual plan happened to come first in the response, which were usually
|
|
71
|
+
// different tiers entirely.
|
|
72
|
+
const bestSaving = tiers.reduce((best, tier) => tier.annualSavingPercent !== null && (best === null || tier.annualSavingPercent > best)
|
|
73
|
+
? tier.annualSavingPercent
|
|
74
|
+
: best, null);
|
|
75
|
+
/**
|
|
76
|
+
* Hands off to Connect and starts watching for the result.
|
|
77
|
+
*
|
|
78
|
+
* The settled test is "the live plan is now the one we sent them to
|
|
79
|
+
* change to" - the same test for a first subscribe and for a plan change,
|
|
80
|
+
* and true in both cases the moment Connect writes the row. It does *not*
|
|
81
|
+
* wait for `hasActiveSubscription`: on the paid path the subscription is
|
|
82
|
+
* created in `"created"` and only flips to active when the payment webhook
|
|
83
|
+
* lands, which can be minutes later. Holding a customer on a spinner until
|
|
84
|
+
* then would misreport a completed checkout as an abandoned one.
|
|
85
|
+
*/
|
|
86
|
+
function startCheckout(plan) {
|
|
87
|
+
if (!serviceId)
|
|
88
|
+
return;
|
|
89
|
+
checkout.start({
|
|
90
|
+
url: (0, use_checkout_js_1.buildCheckoutUrl)({
|
|
91
|
+
appUrl,
|
|
92
|
+
serviceId,
|
|
93
|
+
planId: plan.id,
|
|
94
|
+
subscriptionId: subscription?.subscription?.id ?? null,
|
|
95
|
+
coupon,
|
|
96
|
+
}),
|
|
97
|
+
isSettled: (next) => next.subscription.plan?.id === plan.id,
|
|
98
|
+
});
|
|
99
|
+
setPending(null);
|
|
100
|
+
}
|
|
101
|
+
if (plansLoading || entitlementsLoading) {
|
|
102
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: `grid gap-4 sm:grid-cols-2 lg:grid-cols-3 ${className ?? ""}`, children: Array.from({ length: 3 }, (_, index) => ((0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: "h-80 rounded-xl" }, index))) }));
|
|
103
|
+
}
|
|
104
|
+
if (!session?.user) {
|
|
105
|
+
return (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Sign in to see your plan options." });
|
|
106
|
+
}
|
|
107
|
+
if (error) {
|
|
108
|
+
return (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-danger", children: "Could not load plans." });
|
|
109
|
+
}
|
|
110
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: `flex flex-col gap-6 ${className ?? ""}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap items-center justify-between gap-4", children: [hasAnnual && hasMonthly && ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3", children: [(0, jsx_runtime_1.jsx)("span", { className: showAnnual ? "text-sm text-muted" : "text-sm font-medium text-foreground", children: "Monthly" }), (0, jsx_runtime_1.jsx)(react_1.Switch, { "aria-label": "Bill annually", isSelected: showAnnual, onChange: setAnnual, children: (0, jsx_runtime_1.jsx)(react_1.Switch.Content, { children: (0, jsx_runtime_1.jsx)(react_1.Switch.Control, { children: (0, jsx_runtime_1.jsx)(react_1.Switch.Thumb, {}) }) }) }), (0, jsx_runtime_1.jsx)("span", { className: showAnnual ? "text-sm font-medium text-foreground" : "text-sm text-muted", children: "Annual" }), bestSaving !== null && ((0, jsx_runtime_1.jsxs)(react_1.Chip, { className: "bg-accent-soft text-accent", children: ["Save up to ", bestSaving, "%"] }))] })), !hideCoupon && ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Tag, { className: "size-4 text-muted", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)(react_1.Input, { "aria-label": "Coupon code", placeholder: "Coupon code", value: coupon, onChange: (event) => setCoupon(event.target.value.toUpperCase()), className: "w-40" })] }))] }), subscription?.subscription?.appliedDiscount && ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-4 shrink-0", "aria-hidden": "true" }), (0, jsx_runtime_1.jsxs)("span", { children: [(0, plan_pricing_js_1.discountLabel)(subscription.subscription.appliedDiscount), subscription.subscription.appliedDiscount.code
|
|
111
|
+
? ` - code ${subscription.subscription.appliedDiscount.code}`
|
|
112
|
+
: ""] })] })), (0, jsx_runtime_1.jsx)(ConnectCheckoutStatusRow, { checkout: checkout }), (0, jsx_runtime_1.jsx)("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3", children: tiers.map((tier) => {
|
|
113
|
+
// The price point this card is showing. A tier missing the toggled
|
|
114
|
+
// cadence falls back to the one it does sell rather than dropping
|
|
115
|
+
// out of the grid, and says so under the price.
|
|
116
|
+
const plan = pickTierPlan(tier, showAnnual);
|
|
117
|
+
if (!plan)
|
|
118
|
+
return null;
|
|
119
|
+
const action = (0, plan_pricing_js_1.resolveAction)(plan, subscription);
|
|
120
|
+
// "Current" belongs to the tier, not just the exact price point: an
|
|
121
|
+
// org on Growth annual looking at monthly pricing is still on the
|
|
122
|
+
// Growth tier, and marking only the row it literally subscribes to
|
|
123
|
+
// would leave its own card unmarked half the time.
|
|
124
|
+
const isCurrentTier = subscription?.plan != null &&
|
|
125
|
+
(tier.monthly?.id === subscription.plan.id || tier.annual?.id === subscription.plan.id);
|
|
126
|
+
const isCurrent = action === "current";
|
|
127
|
+
const cadenceUnavailable = showAnnual ? tier.annual === null : tier.monthly === null;
|
|
128
|
+
return ((0, jsx_runtime_1.jsxs)(react_1.Card, { className: isCurrentTier ? "border-accent" : undefined, 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)("span", { className: "text-2xl font-semibold text-foreground", children: (0, plan_pricing_js_1.formatPrice)(plan.price) }), (0, jsx_runtime_1.jsxs)("span", { className: "text-sm text-muted", children: ["/", plan.billingInterval === "annual" ? "year" : "month"] }), (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 ", plan.billingInterval === "annual" ? "annually" : "monthly", "."] }))] }), plan.trialDays ? ((0, jsx_runtime_1.jsxs)("p", { className: "text-xs font-medium text-accent", children: [plan.trialDays, "-day free trial"] })) : null, plan.features.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: "flex flex-col gap-1.5", children: plan.features.map((feature) => (
|
|
129
|
+
// `name`, not `key` - the key is an identifier
|
|
130
|
+
// ("campaign-module"), and printing it here is what this
|
|
131
|
+
// card used to do. The description rides along as a
|
|
132
|
+
// title so a feature that needs explaining can have one
|
|
133
|
+
// without a second line in every card.
|
|
134
|
+
(0, jsx_runtime_1.jsxs)("li", { className: "flex items-start gap-2 text-sm text-foreground", title: feature.description || undefined, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Check, { className: "mt-0.5 size-4 shrink-0 text-accent", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("span", { children: feature.name })] }, feature.key))) })), plan.addons.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5 border-t border-separator pt-3", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-xs font-medium uppercase tracking-wide text-muted", children: "Add-ons" }), plan.addons.map((addon) => ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [(0, jsx_runtime_1.jsxs)("span", { className: "text-foreground", children: [addon.name, addon.consumable && ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted", children: [" ", "- ", addon.consumable.includedPerCycle.toLocaleString("en-IN"), " ", addon.consumable.unit, "/cycle"] }))] }), (0, jsx_runtime_1.jsx)("span", { className: "shrink-0 text-muted", children: (0, plan_pricing_js_1.formatPrice)(addon.price) })] }, addon.id)))] })), (0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: [plan.seats.model === "unlimited" && "Unlimited seats", plan.seats.model === "fixed" && `${plan.seats.included} seats included`, plan.seats.model === "per-seat" &&
|
|
135
|
+
`${plan.seats.included ?? 0} seats included, then ${(0, plan_pricing_js_1.formatPrice)(plan.seats.pricePerExtraSeat ?? 0)} each`] })] }), (0, jsx_runtime_1.jsx)(react_1.Card.Footer, { children: (0, jsx_runtime_1.jsxs)(react_1.Button, { className: "w-full", variant: action === "upgrade" || action === "subscribe" || action === "switch-to-annual"
|
|
136
|
+
? "primary"
|
|
137
|
+
: "secondary",
|
|
138
|
+
// Disabled while a hand-off is in flight, not just on the
|
|
139
|
+
// card that started it: the org has one subscription per
|
|
140
|
+
// service, so a second checkout opened against a different
|
|
141
|
+
// plan would race the first and one of them would be
|
|
142
|
+
// rejected on arrival.
|
|
143
|
+
isDisabled: isCurrent || checkout.status === "waiting", onPress: () => setPending({ plan, action }), children: [plan_pricing_js_1.ACTION_LABEL[action], !isCurrent && (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }) })] }, tier.planGroup));
|
|
144
|
+
}) }), subscription?.subscription && ((0, jsx_runtime_1.jsx)(ConnectUnsubscribeRow, { subscription: subscription.subscription, appUrl: appUrl, checkout: checkout })), (0, jsx_runtime_1.jsx)(subscribe_dialog_js_1.ConnectSubscribeDialog, { plan: pending?.plan ?? null, action: pending?.action ?? "subscribe", current: subscription, coupon: hideCoupon ? undefined : coupon, onClose: () => setPending(null), onConfirm: () => pending && startCheckout(pending.plan) })] }));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* What the page says while the customer is off in Connect, and after.
|
|
148
|
+
*
|
|
149
|
+
* Worth its own row rather than a spinner on the button that opened it: the
|
|
150
|
+
* customer is looking at a different window, and what they need on returning
|
|
151
|
+
* is a plain statement of whether it worked - including the case where they
|
|
152
|
+
* came back having changed their mind, which is not an error and must not
|
|
153
|
+
* read as one.
|
|
154
|
+
*/
|
|
155
|
+
function ConnectCheckoutStatusRow({ checkout, }) {
|
|
156
|
+
if (checkout.status === "idle")
|
|
157
|
+
return null;
|
|
158
|
+
if (checkout.status === "waiting") {
|
|
159
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 rounded-lg border border-separator px-3 py-2 text-sm text-muted", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Loader2, { className: "size-4 shrink-0 animate-spin", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("span", { children: "Finish up in the Connect window. This page updates on its own." })] }));
|
|
160
|
+
}
|
|
161
|
+
if (checkout.status === "completed") {
|
|
162
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.CheckCircle2, { className: "size-4 shrink-0", "aria-hidden": "true" }), "Your plan is updated."] }), (0, jsx_runtime_1.jsx)(react_1.Button, { variant: "tertiary", onPress: checkout.reset, children: "Dismiss" })] }));
|
|
163
|
+
}
|
|
164
|
+
// `blocked` and `abandoned` share a row: both mean nothing changed, and both
|
|
165
|
+
// are fixed by going to the same URL - so offer it rather than explaining.
|
|
166
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap items-center justify-between gap-3 rounded-lg border border-separator px-3 py-2 text-sm text-muted", children: [(0, jsx_runtime_1.jsx)("span", { children: checkout.status === "blocked"
|
|
167
|
+
? "Your browser blocked the Connect window."
|
|
168
|
+
: "Nothing changed - the Connect window closed before it was finished." }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [checkout.url && ((0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "secondary", onPress: () => window.open(checkout.url, "_blank", "noopener,noreferrer"), children: ["Open Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })), (0, jsx_runtime_1.jsx)(react_1.Button, { variant: "tertiary", onPress: checkout.reset, children: "Dismiss" })] })] }));
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Cancellation, kept out of the plan grid and stated plainly.
|
|
172
|
+
*
|
|
173
|
+
* Runs through the same hand-off as the plan actions above - Connect's
|
|
174
|
+
* `/billing/subscriptions/{id}/cancel` modal - rather than dropping the
|
|
175
|
+
* customer on the whole billing page to find the button themselves. It is
|
|
176
|
+
* still Connect that confirms it: the effective date and what happens to a
|
|
177
|
+
* part-used cycle both have to be shown before anyone commits, and only
|
|
178
|
+
* Connect knows them.
|
|
179
|
+
*
|
|
180
|
+
* **A subscription already cancelling gets a statement, not a button.** It used
|
|
181
|
+
* to offer "Resume subscription", which was a promise nothing could keep -
|
|
182
|
+
* Connect has no uncancel path at all, so the button deep-linked to a page
|
|
183
|
+
* showing a static "Cancels on ..." chip and no way to act on it. Saying what
|
|
184
|
+
* will happen, and offering the one thing that does work, is the honest
|
|
185
|
+
* version.
|
|
186
|
+
*/
|
|
187
|
+
function ConnectUnsubscribeRow({ subscription, appUrl, checkout, }) {
|
|
188
|
+
const endsOn = subscription.currentBillingCycle?.endDate;
|
|
189
|
+
if (subscription.cancelAtPeriodEnd) {
|
|
190
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap items-center justify-between gap-3 border-t border-separator pt-4", children: [(0, jsx_runtime_1.jsxs)("p", { className: "text-sm text-muted", children: ["Cancels", endsOn ? ` on ${new Date(endsOn).toLocaleDateString()}` : " at the end of this cycle", ". You keep access until then."] }), (0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "tertiary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${subscription.id}`, "_blank", "noopener,noreferrer"), children: ["View on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })] }));
|
|
191
|
+
}
|
|
192
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap items-center justify-between gap-3 border-t border-separator pt-4", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Cancel any time. Access continues to the end of the paid cycle." }), (0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "tertiary", className: "text-danger", isDisabled: checkout.status === "waiting", onPress: () => checkout.start({
|
|
193
|
+
url: (0, use_checkout_js_1.buildCancelUrl)({ appUrl, subscriptionId: subscription.id }),
|
|
194
|
+
// A paused subscription cancels outright (`status` goes to
|
|
195
|
+
// `"cancelled"`); an active one is deferred to the cycle end
|
|
196
|
+
// (`cancelAtPeriodEnd`). Either is the change being waited on.
|
|
197
|
+
isSettled: (next) => next.subscription.subscription?.cancelAtPeriodEnd === true ||
|
|
198
|
+
next.subscription.subscription?.status === "cancelled" ||
|
|
199
|
+
next.subscription.subscription === null,
|
|
200
|
+
}), children: ["Cancel subscription", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })] }));
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Which of a tier's price points to show for the toggled cadence.
|
|
204
|
+
*
|
|
205
|
+
* Falls back to the other cadence rather than rendering nothing: a tier sold
|
|
206
|
+
* monthly-only is a real thing (a free or entry plan usually is), and hiding
|
|
207
|
+
* it while a customer looks at annual pricing loses them an option that is
|
|
208
|
+
* still available to buy.
|
|
209
|
+
*/
|
|
210
|
+
function pickTierPlan(tier, showAnnual) {
|
|
211
|
+
return showAnnual ? (tier.annual ?? tier.monthly) : (tier.monthly ?? tier.annual);
|
|
212
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ConnectReadinessBanner = ConnectReadinessBanner;
|
|
5
|
+
exports.ConnectReadinessGate = ConnectReadinessGate;
|
|
6
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
|
+
const react_1 = require("@heroui/react");
|
|
8
|
+
const lucide_react_1 = require("lucide-react");
|
|
9
|
+
const helpers_js_1 = require("../../helpers.js");
|
|
10
|
+
const provider_js_1 = require("../provider.js");
|
|
11
|
+
/**
|
|
12
|
+
* Codes that mean "somebody at Connect is looking at it" - there is no action
|
|
13
|
+
* for the customer to take, so these get an informational treatment and no
|
|
14
|
+
* button, even though the server may still hand back a `resolveUrl`.
|
|
15
|
+
*/
|
|
16
|
+
const IN_PROGRESS_CODES = new Set([
|
|
17
|
+
"verification_pending",
|
|
18
|
+
"onboarding_approval_pending",
|
|
19
|
+
]);
|
|
20
|
+
/** Codes worth a more specific call to action than a generic "Resolve". */
|
|
21
|
+
const ACTION_LABELS = {
|
|
22
|
+
subscription_missing: "Choose a plan",
|
|
23
|
+
subscription_inactive: "Fix subscription",
|
|
24
|
+
payment_method_missing: "Add a payment method",
|
|
25
|
+
verification_required: "Start verification",
|
|
26
|
+
verification_rejected: "Review verification",
|
|
27
|
+
verification_expired: "Renew verification",
|
|
28
|
+
seat_limit_reached: "Manage seats",
|
|
29
|
+
};
|
|
30
|
+
function actionLabel(blocker) {
|
|
31
|
+
return ACTION_LABELS[blocker.code] ?? "Resolve on Connect";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Every gate between this workspace and your service, with the way out of each.
|
|
35
|
+
*
|
|
36
|
+
* This is the component that keeps a blocked customer from staring at a broken
|
|
37
|
+
* app. `getReadiness` resolves subscription state, KYC/KYB, a chargeable
|
|
38
|
+
* payment method and seat headroom in one call, and **each blocker carries a
|
|
39
|
+
* `resolveUrl` into the exact Connect screen that fixes it** - so being blocked
|
|
40
|
+
* comes with somewhere to go rather than a refusal.
|
|
41
|
+
*
|
|
42
|
+
* Render it high on any page your service gates. It draws nothing at all when
|
|
43
|
+
* the workspace is ready, so it is safe to leave mounted permanently.
|
|
44
|
+
*
|
|
45
|
+
* Two deliberate refusals:
|
|
46
|
+
*
|
|
47
|
+
* - **Unrecognised codes are still shown, and still treated as blocking.**
|
|
48
|
+
* Connect adds codes without a major version (see `ReadinessBlockerCode`), so
|
|
49
|
+
* a switch that only knew today's codes would silently drop tomorrow's - the
|
|
50
|
+
* one failure mode where a customer is locked out with no explanation.
|
|
51
|
+
* - **`message` is rendered, never parsed.** It is human-facing and subject to
|
|
52
|
+
* rewording; branching on its text would break on a copy edit.
|
|
53
|
+
*/
|
|
54
|
+
function ConnectReadinessBanner({ includeAdvisory = false, hideWhenReady = true, className, }) {
|
|
55
|
+
const { session } = (0, provider_js_1.useConnect)();
|
|
56
|
+
const { data, isLoading } = (0, provider_js_1.useConnectEntitlements)();
|
|
57
|
+
// Signed out is not "blocked" - it is a different question, and answering it
|
|
58
|
+
// here would put a verification banner in front of a login button.
|
|
59
|
+
if (!session?.user)
|
|
60
|
+
return null;
|
|
61
|
+
if (isLoading) {
|
|
62
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: `flex items-center gap-2 text-sm text-muted ${className ?? ""}`, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Loader2, { className: "size-4 animate-spin", "aria-hidden": "true" }), "Checking your workspace\u2026"] }));
|
|
63
|
+
}
|
|
64
|
+
if (!data)
|
|
65
|
+
return null;
|
|
66
|
+
const blockers = includeAdvisory ? data.readiness.readiness.blockers : (0, helpers_js_1.blockingBlockers)(data.readiness);
|
|
67
|
+
if (blockers.length === 0)
|
|
68
|
+
return hideWhenReady ? null : (0, jsx_runtime_1.jsx)(ReadyRow, { className: className });
|
|
69
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: `flex flex-col gap-2 ${className ?? ""}`, role: "status", children: blockers.map((blocker) => ((0, jsx_runtime_1.jsx)(BlockerRow, { blocker: blocker }, `${blocker.code}-${blocker.message}`))) }));
|
|
70
|
+
}
|
|
71
|
+
function BlockerRow({ blocker }) {
|
|
72
|
+
const inProgress = IN_PROGRESS_CODES.has(blocker.code);
|
|
73
|
+
const isBlocking = blocker.severity === "blocking";
|
|
74
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm ${inProgress || !isBlocking ? "bg-surface text-muted" : "bg-danger-soft text-danger"}`, children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2", children: [inProgress || !isBlocking ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Info, { className: "size-4 shrink-0", "aria-hidden": "true" })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" })), blocker.message] }), blocker.resolveUrl && !inProgress && ((0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "secondary", onPress: () => window.open(blocker.resolveUrl, "_blank", "noopener,noreferrer"), children: [actionLabel(blocker), (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }))] }));
|
|
75
|
+
}
|
|
76
|
+
function ReadyRow({ className }) {
|
|
77
|
+
return ((0, jsx_runtime_1.jsx)("p", { className: `text-sm text-muted ${className ?? ""}`, children: "This workspace is cleared to use every part of the service." }));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Renders `children` only once the workspace clears every blocking gate.
|
|
81
|
+
*
|
|
82
|
+
* The hard version of `ConnectReadinessBanner`, for a route that genuinely
|
|
83
|
+
* cannot function while blocked. Prefer the banner where the page still does
|
|
84
|
+
* something useful - a gate that hides a whole screen behind a verification
|
|
85
|
+
* notice is a worse experience than one that lets people look around while
|
|
86
|
+
* they sort it out.
|
|
87
|
+
*
|
|
88
|
+
* **This is a UX gate, not a security boundary.** The readiness read reaches
|
|
89
|
+
* your own origin and a determined browser can ignore whatever it says. The
|
|
90
|
+
* authoritative check is `client.entitlements.getReadiness` on your server,
|
|
91
|
+
* before you do the thing.
|
|
92
|
+
*/
|
|
93
|
+
function ConnectReadinessGate({ children, fallback, loading, className, }) {
|
|
94
|
+
const { session, isLoading: sessionLoading } = (0, provider_js_1.useConnect)();
|
|
95
|
+
const { data, isLoading } = (0, provider_js_1.useConnectEntitlements)();
|
|
96
|
+
if (sessionLoading || (session?.user && isLoading)) {
|
|
97
|
+
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: loading ?? (0, jsx_runtime_1.jsx)(ConnectReadinessBanner, { className: className }) });
|
|
98
|
+
}
|
|
99
|
+
if (!session?.user)
|
|
100
|
+
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: fallback ?? null });
|
|
101
|
+
if (data && isReady(data.readiness))
|
|
102
|
+
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: children });
|
|
103
|
+
return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: fallback ?? ((0, jsx_runtime_1.jsx)(react_1.Card, { className: className, children: (0, jsx_runtime_1.jsx)(react_1.Card.Content, { className: "py-6", children: (0, jsx_runtime_1.jsx)(ConnectReadinessBanner, {}) }) })) }));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* `ready` as the server computed it, with a local fallback.
|
|
107
|
+
*
|
|
108
|
+
* The server's own `ready` flag is authoritative and used when present; the
|
|
109
|
+
* blocker scan behind it exists so an older Connect that omits the field
|
|
110
|
+
* cannot be read as "ready" by default. Failing open on an absent gate is the
|
|
111
|
+
* wrong direction for a field that means "may this workspace be here at all".
|
|
112
|
+
*/
|
|
113
|
+
function isReady(readiness) {
|
|
114
|
+
return typeof readiness.ready === "boolean"
|
|
115
|
+
? readiness.ready
|
|
116
|
+
: (0, helpers_js_1.blockingBlockers)(readiness).length === 0;
|
|
117
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ConnectSubscribeDialog = ConnectSubscribeDialog;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("@heroui/react");
|
|
7
|
+
const lucide_react_1 = require("lucide-react");
|
|
8
|
+
const plan_pricing_js_1 = require("./plan-pricing.js");
|
|
9
|
+
/** GST is quoted exclusive everywhere in Connect; this is the same arithmetic `calculateGST` does server-side. */
|
|
10
|
+
function withGst(price, gstRate) {
|
|
11
|
+
return price + Math.round(price * (gstRate / 100));
|
|
12
|
+
}
|
|
13
|
+
function cadenceOf(plan) {
|
|
14
|
+
return plan.billingInterval === "annual" ? "year" : "month";
|
|
15
|
+
}
|
|
16
|
+
const HEADING = {
|
|
17
|
+
subscribe: "Confirm subscription",
|
|
18
|
+
upgrade: "Confirm upgrade",
|
|
19
|
+
downgrade: "Confirm downgrade",
|
|
20
|
+
// A change of cadence is neither, and must not borrow either word - moving
|
|
21
|
+
// to annual billing costs less per month but more today, so calling it a
|
|
22
|
+
// downgrade misdescribes both the product and the bill. See `PlanAction`.
|
|
23
|
+
"switch-to-annual": "Switch to annual billing",
|
|
24
|
+
"switch-to-monthly": "Switch to monthly billing",
|
|
25
|
+
current: "Current plan",
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The review step before a customer is sent to Connect.
|
|
29
|
+
*
|
|
30
|
+
* It exists because the hand-off is a context switch, and a context switch
|
|
31
|
+
* the customer did not expect reads as being dumped somewhere. So this says
|
|
32
|
+
* what they are about to buy, what it costs with GST, what happens to the
|
|
33
|
+
* cycle they are part-way through, and - plainly - that the payment itself
|
|
34
|
+
* happens on Connect. Then one button, which opens it.
|
|
35
|
+
*
|
|
36
|
+
* **Every number here is a preview, and deliberately never the last word.**
|
|
37
|
+
* The authoritative price is computed by Connect against the real cart:
|
|
38
|
+
* proration is priced against the actual cycle, a coupon is validated
|
|
39
|
+
* against the plan it is being applied to, trial eligibility depends on
|
|
40
|
+
* whether this organization has ever subscribed to this service before, and
|
|
41
|
+
* an accumulated credit is deducted. None of that is knowable from the
|
|
42
|
+
* partner's origin, so this component states the list price and says where
|
|
43
|
+
* the final figure comes from rather than quietly guessing at one.
|
|
44
|
+
*/
|
|
45
|
+
function ConnectSubscribeDialog({ plan, action, current, coupon, onClose, onConfirm, }) {
|
|
46
|
+
const currentPlan = current?.plan ?? null;
|
|
47
|
+
const isIntervalSwitch = action === "switch-to-annual" || action === "switch-to-monthly";
|
|
48
|
+
// Every action except a first subscribe is a move *from* something, so all
|
|
49
|
+
// of them get the from -> to comparison. A cadence switch is the newest
|
|
50
|
+
// member and the one most in need of it: the two prices differ by an order
|
|
51
|
+
// of magnitude, and showing only the new one reads as a price rise.
|
|
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), "/", currentPlan.billingInterval === "annual" ? "yr" : "mo"] })] }), (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), "/", plan.billingInterval === "annual" ? "yr" : "mo"] })] })] })) : ((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 service before."] })) : null, coupon?.trim() ? ((0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: ["Coupon ", (0, jsx_runtime_1.jsx)("span", { className: "font-mono text-foreground", children: coupon.trim() }), " is carried over and validated on Connect."] })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "flex gap-2 rounded-lg bg-surface p-3 text-xs text-muted", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Info, { className: "mt-0.5 size-4 shrink-0", "aria-hidden": "true" }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1", children: [action === "subscribe" && ((0, jsx_runtime_1.jsx)("p", { children: "Payment is completed on Connect, where your card or UPI mandate is set up and the tax invoice is issued. This page updates by itself once it is done." })), action === "upgrade" && ((0, jsx_runtime_1.jsxs)("p", { children: ["Connect prices the difference for the days left in your current cycle and collects just that amount now. From the next cycle you are billed the ", plan.name, " rate."] })), action === "downgrade" && ((0, jsx_runtime_1.jsx)("p", { children: "You keep what you have paid for. Connect works out the unused part of this cycle and applies it as credit or a refund, depending on how this service is configured." })), isIntervalSwitch && (
|
|
54
|
+
// Not a proration. The cycle you are on ends, so this is
|
|
55
|
+
// the one action where the amount collected can exceed a
|
|
56
|
+
// single cycle's list price - or be nothing at all, with
|
|
57
|
+
// the balance carried as credit. Saying so here is the
|
|
58
|
+
// difference between an expected bill and a surprise.
|
|
59
|
+
(0, jsx_runtime_1.jsxs)("p", { children: ["Your billing cycle restarts on confirmation. Connect values the days left on", " ", currentPlan?.name ?? "your current plan", ", takes that off a full", " ", plan.billingInterval === "annual" ? "year" : "month", " at the ", plan.name, " rate, and collects the difference. Anything left over is carried as credit against your next invoices rather than refunded."] })), (0, jsx_runtime_1.jsx)("p", { children: "Nothing is charged from this page - it has no access to your payment details." })] })] })] }), (0, jsx_runtime_1.jsxs)(react_1.Modal.Footer, { children: [(0, jsx_runtime_1.jsx)(react_1.Button, { variant: "secondary", onPress: onClose, children: "Cancel" }), (0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "primary", onPress: onConfirm, children: ["Continue on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })] })] }))] }) }) }));
|
|
60
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.resolveSubscriptionState = resolveSubscriptionState;
|
|
5
|
+
exports.ConnectNoSubscriptionCard = ConnectNoSubscriptionCard;
|
|
6
|
+
exports.ConnectSubscriptionCard = ConnectSubscriptionCard;
|
|
7
|
+
exports.ConnectSeatUsage = ConnectSeatUsage;
|
|
8
|
+
exports.ConnectTrialBanner = ConnectTrialBanner;
|
|
9
|
+
exports.ConnectSubscriptionGate = ConnectSubscriptionGate;
|
|
10
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
11
|
+
const react_1 = require("@heroui/react");
|
|
12
|
+
const lucide_react_1 = require("lucide-react");
|
|
13
|
+
const provider_js_1 = require("../provider.js");
|
|
14
|
+
const plan_pricing_js_1 = require("./plan-pricing.js");
|
|
15
|
+
const link_button_js_1 = require("./link-button.js");
|
|
16
|
+
/** Where the plan catalogue lives in the host app. Overridable, because not every app mounts it at `/plans`. */
|
|
17
|
+
const DEFAULT_PLANS_HREF = "/plans";
|
|
18
|
+
function formatDate(iso) {
|
|
19
|
+
return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
|
20
|
+
}
|
|
21
|
+
function resolveSubscriptionState(subscription, isTrialing) {
|
|
22
|
+
const detail = subscription?.subscription;
|
|
23
|
+
if (!detail)
|
|
24
|
+
return "none";
|
|
25
|
+
if (detail.status === "paused" || detail.status === "cancelled")
|
|
26
|
+
return "problem";
|
|
27
|
+
if (detail.status === "created" || detail.status === "authenticated")
|
|
28
|
+
return "pending";
|
|
29
|
+
if (detail.cancelAtPeriodEnd)
|
|
30
|
+
return "cancelling";
|
|
31
|
+
return isTrialing ? "trialing" : "active";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `isTrialing` lives on the *session* block, not on `SubscriptionDetail`.
|
|
35
|
+
*
|
|
36
|
+
* A small wart of the wire format worth centralising rather than rediscovering:
|
|
37
|
+
* `/entitlements` describes the subscription in full but cannot tell trialing
|
|
38
|
+
* from paying, and `/session` carries the one boolean that can. Both come from
|
|
39
|
+
* the same provider, so reading across them here costs nothing.
|
|
40
|
+
*/
|
|
41
|
+
function useSubscriptionState() {
|
|
42
|
+
const { session } = (0, provider_js_1.useConnect)();
|
|
43
|
+
const { data, isLoading } = (0, provider_js_1.useConnectEntitlements)();
|
|
44
|
+
const subscription = data?.subscription ?? null;
|
|
45
|
+
return {
|
|
46
|
+
isLoading,
|
|
47
|
+
subscription,
|
|
48
|
+
detail: subscription?.subscription ?? null,
|
|
49
|
+
plan: subscription?.plan ?? null,
|
|
50
|
+
state: resolveSubscriptionState(subscription, session?.subscription?.isTrialing ?? false),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The "you are not on a plan yet" card.
|
|
55
|
+
*
|
|
56
|
+
* Points at your **own** `/plans` page rather than opening Connect, and that is
|
|
57
|
+
* the deliberate part: choosing a plan is browsing, it belongs in your product
|
|
58
|
+
* where your pricing story is, and only the final confirmation needs to happen
|
|
59
|
+
* on Connect. Sending someone straight out of the app to pick is a context
|
|
60
|
+
* switch two steps too early.
|
|
61
|
+
*
|
|
62
|
+
* Rendered on its own it is just a card; `ConnectSubscriptionGate` is the usual
|
|
63
|
+
* way in, since it picks this only when there is genuinely nothing to show.
|
|
64
|
+
*/
|
|
65
|
+
function ConnectNoSubscriptionCard({ plansHref = DEFAULT_PLANS_HREF, title = "You're not on a plan yet", description = "Pick a plan to unlock the full service. You can change or cancel it whenever you like.", className, }) {
|
|
66
|
+
return ((0, jsx_runtime_1.jsxs)(react_1.Card, { className: className, children: [(0, jsx_runtime_1.jsxs)(react_1.Card.Header, { children: [(0, jsx_runtime_1.jsx)(react_1.Card.Title, { children: title }), (0, jsx_runtime_1.jsx)(react_1.Card.Description, { children: description })] }), (0, jsx_runtime_1.jsx)(react_1.Card.Footer, { children: (0, jsx_runtime_1.jsxs)(link_button_js_1.ConnectLinkButton, { href: plansHref, variant: "primary", children: ["See plans", (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowRight, { className: "size-3.5" })] }) })] }));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The current subscription at a glance: plan, what it costs, what happens next,
|
|
70
|
+
* and one way into Connect to change it.
|
|
71
|
+
*
|
|
72
|
+
* The counterpart to `ConnectNoSubscriptionCard` - the same slot in the
|
|
73
|
+
* signed-up state - so an account or settings page can render this and get the
|
|
74
|
+
* right thing either way.
|
|
75
|
+
*
|
|
76
|
+
* Dates are formatted, never computed. "Renews on 3 Mar" is a fact from the
|
|
77
|
+
* wire; "renews in 12 days" would be a clock read during render, which is
|
|
78
|
+
* impure and re-renders wrong.
|
|
79
|
+
*/
|
|
80
|
+
function ConnectSubscriptionCard({ plansHref, className }) {
|
|
81
|
+
const { appUrl } = (0, provider_js_1.useConnect)();
|
|
82
|
+
const { isLoading, detail, plan, state } = useSubscriptionState();
|
|
83
|
+
if (isLoading)
|
|
84
|
+
return (0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: `h-48 rounded-xl ${className ?? ""}` });
|
|
85
|
+
if (!detail || !plan)
|
|
86
|
+
return (0, jsx_runtime_1.jsx)(ConnectNoSubscriptionCard, { plansHref: plansHref, className: className });
|
|
87
|
+
return ((0, jsx_runtime_1.jsxs)(react_1.Card, { className: className, 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: plan.name }), (0, jsx_runtime_1.jsx)(SubscriptionChip, { state: state })] }), (0, jsx_runtime_1.jsxs)(react_1.Card.Description, { children: [(0, plan_pricing_js_1.formatPrice)(plan.price), " + ", plan.gstRate, "% GST per", " ", plan.billingInterval === "annual" ? "year" : "month"] })] }), (0, jsx_runtime_1.jsxs)(react_1.Card.Content, { className: "flex flex-col gap-2 text-sm", children: [(0, jsx_runtime_1.jsx)(NextEvent, { detail: detail, state: state }), (0, jsx_runtime_1.jsx)(ConnectSeatUsage, { plan: plan, detail: detail })] }), (0, jsx_runtime_1.jsxs)(react_1.Card.Footer, { className: "flex flex-wrap gap-2", children: [(0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage on Connect", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] }), (0, jsx_runtime_1.jsx)(link_button_js_1.ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Change plan" })] })] }));
|
|
88
|
+
}
|
|
89
|
+
function SubscriptionChip({ state }) {
|
|
90
|
+
switch (state) {
|
|
91
|
+
case "trialing":
|
|
92
|
+
return (0, jsx_runtime_1.jsx)(react_1.Chip, { className: "bg-accent-soft text-accent", children: "Free trial" });
|
|
93
|
+
case "cancelling":
|
|
94
|
+
return (0, jsx_runtime_1.jsx)(react_1.Chip, { className: "bg-surface text-muted", children: "Ending soon" });
|
|
95
|
+
case "pending":
|
|
96
|
+
return (0, jsx_runtime_1.jsx)(react_1.Chip, { className: "bg-surface text-muted", children: "Confirming payment" });
|
|
97
|
+
case "problem":
|
|
98
|
+
return (0, jsx_runtime_1.jsx)(react_1.Chip, { className: "bg-danger-soft text-danger", children: "Needs attention" });
|
|
99
|
+
default:
|
|
100
|
+
return (0, jsx_runtime_1.jsx)(react_1.Chip, { className: "bg-accent-soft text-accent", children: "Active" });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** The one sentence that matters most: what happens next, and when. */
|
|
104
|
+
function NextEvent({ detail, state, }) {
|
|
105
|
+
const endsOn = detail.currentBillingCycle?.endDate;
|
|
106
|
+
if (state === "cancelling") {
|
|
107
|
+
return ((0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2 text-muted", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Clock, { className: "size-4 shrink-0", "aria-hidden": "true" }), endsOn ? `Access ends on ${formatDate(endsOn)}.` : "Access ends at the end of this cycle."] }));
|
|
108
|
+
}
|
|
109
|
+
if (state === "pending") {
|
|
110
|
+
return ((0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2 text-muted", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.CreditCard, { className: "size-4 shrink-0", "aria-hidden": "true" }), "Waiting for your first payment to confirm."] }));
|
|
111
|
+
}
|
|
112
|
+
if (state === "problem") {
|
|
113
|
+
return ((0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2 text-danger", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" }), "This subscription isn't active. Settle it on Connect to restore access."] }));
|
|
114
|
+
}
|
|
115
|
+
if (!detail.nextBillingDate)
|
|
116
|
+
return null;
|
|
117
|
+
return ((0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2 text-muted", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Clock, { className: "size-4 shrink-0", "aria-hidden": "true" }), state === "trialing"
|
|
118
|
+
? `Your first charge is on ${formatDate(detail.nextBillingDate)}.`
|
|
119
|
+
: `Renews on ${formatDate(detail.nextBillingDate)}.`] }));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Seats used against what the plan includes.
|
|
123
|
+
*
|
|
124
|
+
* Only meaningful for `"fixed"` and `"per-seat"` - `"unlimited"` has nothing to
|
|
125
|
+
* count against, so it renders nothing rather than a meter that is always
|
|
126
|
+
* empty. Under `"per-seat"` going over is billable rather than blocked, so it
|
|
127
|
+
* says what the extra costs instead of warning.
|
|
128
|
+
*/
|
|
129
|
+
function ConnectSeatUsage({ plan, detail, className, }) {
|
|
130
|
+
if (plan.seats.model === "unlimited")
|
|
131
|
+
return null;
|
|
132
|
+
const included = plan.seats.included ?? 0;
|
|
133
|
+
const over = detail.seatCount - included;
|
|
134
|
+
return ((0, jsx_runtime_1.jsxs)("span", { className: `flex items-center gap-2 text-muted ${className ?? ""}`, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Users, { className: "size-4 shrink-0", "aria-hidden": "true" }), detail.seatCount, " of ", included, " seat", included === 1 ? "" : "s", " used", over > 0 &&
|
|
135
|
+
(plan.seats.model === "per-seat"
|
|
136
|
+
? ` - ${over} extra at ${(0, plan_pricing_js_1.formatPrice)(plan.seats.pricePerExtraSeat ?? 0)} each`
|
|
137
|
+
: ` - ${over} over the plan's limit`)] }));
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* A trial that is running, and when it converts.
|
|
141
|
+
*
|
|
142
|
+
* Renders only while trialing, so it can live permanently in a layout. It says
|
|
143
|
+
* the charge date rather than a countdown for the same reason
|
|
144
|
+
* `ConnectSubscriptionCard` does - a countdown means reading the clock while
|
|
145
|
+
* rendering.
|
|
146
|
+
*
|
|
147
|
+
* The wire carries `isTrialing` but no trial *end* date; `nextBillingDate` on a
|
|
148
|
+
* trialing subscription is the first charge, which is the date a customer
|
|
149
|
+
* actually cares about anyway.
|
|
150
|
+
*/
|
|
151
|
+
function ConnectTrialBanner({ plansHref, className }) {
|
|
152
|
+
const { appUrl } = (0, provider_js_1.useConnect)();
|
|
153
|
+
const { detail, state } = useSubscriptionState();
|
|
154
|
+
if (state !== "trialing" || !detail)
|
|
155
|
+
return null;
|
|
156
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent ${className ?? ""}`, children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Clock, { className: "size-4 shrink-0", "aria-hidden": "true" }), "You're on a free trial", detail.nextBillingDate ? `. Your first charge is on ${formatDate(detail.nextBillingDate)}.` : "."] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(link_button_js_1.ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Compare plans" }), (0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5" })] })] })] }));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Renders `children` for a subscribed workspace, and the "pick a plan" card for
|
|
160
|
+
* everyone else.
|
|
161
|
+
*
|
|
162
|
+
* **Never renders the fallback while loading.** That flash - "you're not
|
|
163
|
+
* subscribed" for 200ms, then the real content - is the specific bug this
|
|
164
|
+
* component exists to make impossible, and it is what hand-rolled versions of
|
|
165
|
+
* it get wrong: the honest states are "still asking" and "asked, and the answer
|
|
166
|
+
* is no", and only the second one is a sales pitch.
|
|
167
|
+
*
|
|
168
|
+
* **This is a UX gate, not a security boundary** - same caveat as
|
|
169
|
+
* `ConnectReadinessGate`. Anything that must not happen without a subscription
|
|
170
|
+
* is checked on your server with `client.entitlements.hasFeature`.
|
|
171
|
+
*/
|
|
172
|
+
function ConnectSubscriptionGate({ children, plansHref, fallback, loading, allowTrialing = true, allowPending = true, className, }) {
|
|
173
|
+
const { session, isLoading: sessionLoading } = (0, provider_js_1.useConnect)();
|
|
174
|
+
const { isLoading, state } = useSubscriptionState();
|
|
175
|
+
if (sessionLoading || (session?.user && isLoading)) {
|
|
176
|
+
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: loading ?? (0, jsx_runtime_1.jsx)(react_1.Skeleton, { className: `h-48 rounded-xl ${className ?? ""}` }) });
|
|
177
|
+
}
|
|
178
|
+
if (!session?.user)
|
|
179
|
+
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: fallback ?? null });
|
|
180
|
+
const allowed = state === "active" ||
|
|
181
|
+
(allowTrialing && state === "trialing") ||
|
|
182
|
+
(allowPending && state === "pending") ||
|
|
183
|
+
// Cancelling still means paid-for access until the cycle ends. Locking
|
|
184
|
+
// someone out the moment they press cancel takes away what they bought.
|
|
185
|
+
state === "cancelling";
|
|
186
|
+
if (allowed)
|
|
187
|
+
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: children });
|
|
188
|
+
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: fallback ?? (0, jsx_runtime_1.jsx)(ConnectNoSubscriptionCard, { plansHref: plansHref, className: className }) });
|
|
189
|
+
}
|