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