@startsimpli/billing 0.4.12 → 0.4.15
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/README.md +285 -87
- package/package.json +14 -13
- package/src/components/CurrentPlanCard.test.tsx +289 -0
- package/src/components/CurrentPlanCard.tsx +317 -0
- package/src/components/LimitNudge.test.tsx +132 -0
- package/src/components/LimitNudge.tsx +84 -0
- package/src/components/SubscriptionManager.test.tsx +772 -20
- package/src/components/SubscriptionManager.tsx +337 -81
- package/src/components/index.ts +4 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useUsage.ts +94 -0
- package/src/index.ts +7 -1
- package/src/mock/__tests__/mock-backend.test.ts +128 -0
- package/src/mock/backend.ts +25 -0
- package/src/mock/index.ts +7 -0
- package/src/mock/mock-backend.ts +103 -0
- package/src/types/index.ts +44 -1
- package/src/utils/api.ts +72 -1
|
@@ -12,11 +12,15 @@
|
|
|
12
12
|
* />
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import React from "react";
|
|
15
|
+
import React, { useEffect, useRef } from "react";
|
|
16
16
|
import { useSubscription } from "../hooks/useSubscription";
|
|
17
17
|
import { useProduct } from "../hooks/useProduct";
|
|
18
18
|
import { usePortal } from "../hooks/usePortal";
|
|
19
|
-
import
|
|
19
|
+
import { useCheckout } from "../hooks/useCheckout";
|
|
20
|
+
import { useUsage } from "../hooks/useUsage";
|
|
21
|
+
import { useSuccessSync } from "../hooks/useSuccessSync";
|
|
22
|
+
import type { OfferFeature, ProductOffer } from "../types";
|
|
23
|
+
import { CurrentPlanCard } from "./CurrentPlanCard";
|
|
20
24
|
|
|
21
25
|
export interface SubscriptionManagerProps {
|
|
22
26
|
/** Product slug (e.g., "raise-simpli") */
|
|
@@ -32,6 +36,78 @@ export interface SubscriptionManagerProps {
|
|
|
32
36
|
statusMessage?: "success" | "cancelled" | "activating" | null;
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
/** Render one OfferFeature row. Skips boolean-false (the plan _doesn't_ have
|
|
40
|
+
* that feature so we don't pretend it does). Numeric limits surface as
|
|
41
|
+
* "Unlimited X" (-1) / "Up to N X" / "X" (no limit attribute).
|
|
42
|
+
* startsim-o7u — kills the blank gap between price and CTA on plan cards. */
|
|
43
|
+
function FeatureRow({ feature }: { feature: OfferFeature }) {
|
|
44
|
+
const v = feature.value;
|
|
45
|
+
if (v === false) return null;
|
|
46
|
+
|
|
47
|
+
let label = feature.name;
|
|
48
|
+
if (typeof v === "number" || typeof feature.limit === "number") {
|
|
49
|
+
const limit = typeof feature.limit === "number" ? feature.limit
|
|
50
|
+
: typeof v === "number" ? v : null;
|
|
51
|
+
if (limit === -1) label = `Unlimited ${feature.name.toLowerCase()}`;
|
|
52
|
+
else if (limit !== null && limit > 0) label = `Up to ${limit} ${feature.name.toLowerCase()}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<li className="flex items-start gap-2 text-sm text-gray-700">
|
|
57
|
+
<svg className="w-4 h-4 text-green-600 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
58
|
+
stroke="currentColor" strokeWidth={2.5} aria-hidden="true">
|
|
59
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
60
|
+
</svg>
|
|
61
|
+
<span>{label}</span>
|
|
62
|
+
</li>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function PlanFeatureList({ features }: { features: OfferFeature[] }) {
|
|
67
|
+
if (!features || features.length === 0) return null;
|
|
68
|
+
return (
|
|
69
|
+
<ul data-testid="plan-features" className="space-y-1.5 mb-4">
|
|
70
|
+
{features.map((f) => <FeatureRow key={f.key} feature={f} />)}
|
|
71
|
+
</ul>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Subtle trial copy below the price block on offers that ship a trial.
|
|
76
|
+
* startsim-pih — so users know "Start Free Trial" actually IS a free trial. */
|
|
77
|
+
function TrialCopy({ trialDays }: { trialDays: number }) {
|
|
78
|
+
if (!trialDays || trialDays <= 0) return null;
|
|
79
|
+
return (
|
|
80
|
+
<p data-testid="trial-copy" className="-mt-1 mb-3 text-xs text-gray-500">
|
|
81
|
+
{trialDays}-day free trial · no card required
|
|
82
|
+
</p>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Layout-matching skeleton block for SubscriptionManager loading state.
|
|
87
|
+
* startsim-z6f — replaces the bare "Loading subscription…" text. */
|
|
88
|
+
function PlanSkeleton({ testId, tall = false }: { testId: string; tall?: boolean }) {
|
|
89
|
+
return (
|
|
90
|
+
<div data-testid={testId} className={`rounded-xl border border-gray-200 bg-white shadow-sm ${tall ? "p-8" : "p-6"} animate-pulse`}>
|
|
91
|
+
<div className="flex items-center justify-between mb-4">
|
|
92
|
+
<div className="h-6 w-24 bg-gray-200 rounded" />
|
|
93
|
+
<div className="h-5 w-16 bg-gray-100 rounded-full" />
|
|
94
|
+
</div>
|
|
95
|
+
<div className="h-9 w-32 bg-gray-200 rounded mb-6" />
|
|
96
|
+
{tall && (
|
|
97
|
+
<div className="space-y-3">
|
|
98
|
+
{[0, 1, 2, 3].map((i) => (
|
|
99
|
+
<div key={i} className="flex items-center gap-3">
|
|
100
|
+
<div className="h-4 w-4 bg-gray-200 rounded" />
|
|
101
|
+
<div className="h-4 flex-1 bg-gray-100 rounded" />
|
|
102
|
+
</div>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
<div className={`${tall ? "mt-6" : "mt-4"} h-9 w-full bg-gray-100 rounded-xl`} />
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
35
111
|
function formatDate(iso: string): string {
|
|
36
112
|
const d = new Date(iso);
|
|
37
113
|
const year = d.getUTCFullYear();
|
|
@@ -96,33 +172,138 @@ export function SubscriptionManager({
|
|
|
96
172
|
onPlanChange,
|
|
97
173
|
statusMessage,
|
|
98
174
|
}: SubscriptionManagerProps) {
|
|
99
|
-
const { subscription, loading: subLoading, error: subError } = useSubscription();
|
|
100
|
-
const { product, loading: productLoading } = useProduct(productId);
|
|
175
|
+
const { subscription, loading: subLoading, error: subError, refetch: refetchSubscription } = useSubscription();
|
|
176
|
+
const { product, loading: productLoading, error: productError, refetch: refetchProduct } = useProduct(productId);
|
|
101
177
|
const { openPortal, loading: portalLoading } = usePortal();
|
|
178
|
+
const { subscribeFree } = useCheckout();
|
|
179
|
+
const { usage } = useUsage(productId);
|
|
180
|
+
const { sync: successSync } = useSuccessSync();
|
|
102
181
|
|
|
103
|
-
|
|
182
|
+
// startsim-ngw — when the user lands on ?status=success (just back from
|
|
183
|
+
// Stripe checkout), POST /billing/success-sync/ ONCE to populate the cache
|
|
184
|
+
// from Stripe so the UI flips to Trialing/Active without waiting for the
|
|
185
|
+
// webhook, then refetch the subscription. The ref guard prevents the effect
|
|
186
|
+
// re-firing on React StrictMode double-invokes.
|
|
187
|
+
const successSyncedRef = useRef(false);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (statusMessage === "success" && !successSyncedRef.current) {
|
|
190
|
+
successSyncedRef.current = true;
|
|
191
|
+
(async () => {
|
|
192
|
+
try {
|
|
193
|
+
await successSync();
|
|
194
|
+
} finally {
|
|
195
|
+
await refetchSubscription();
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
}
|
|
199
|
+
}, [statusMessage, successSync, refetchSubscription]);
|
|
104
200
|
|
|
105
|
-
|
|
201
|
+
// startsim-z6f: skeleton matching the final layout (current-plan box +
|
|
202
|
+
// 2-card grid) so the transition from loading → loaded doesn't reflow.
|
|
203
|
+
// startsim-mtv: tolerate partial loads — sub-loaded-but-product-not is
|
|
204
|
+
// common (the two endpoints fetch independently), and crashing the page
|
|
205
|
+
// during that window is worse than rendering only the half that's ready.
|
|
206
|
+
if (subLoading && productLoading) {
|
|
106
207
|
return (
|
|
107
|
-
<div data-testid="subscription-manager" className="max-w-3xl mx-auto px-6 py-12">
|
|
108
|
-
<
|
|
208
|
+
<div data-testid="subscription-manager" className="max-w-3xl mx-auto px-6 py-12 space-y-8">
|
|
209
|
+
<PlanSkeleton testId="skeleton-current-plan" tall />
|
|
210
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
211
|
+
<PlanSkeleton testId="skeleton-plan-card" />
|
|
212
|
+
<PlanSkeleton testId="skeleton-plan-card" />
|
|
213
|
+
</div>
|
|
109
214
|
</div>
|
|
110
215
|
);
|
|
111
216
|
}
|
|
112
217
|
|
|
113
|
-
|
|
218
|
+
// Subscription-fetch error gets a tight inline message instead of nuking the
|
|
219
|
+
// entire page — users can still see plan cards (once product loads) and try
|
|
220
|
+
// a refresh. startsim-c0r is the product-side equivalent below.
|
|
221
|
+
if (subError && !subscription) {
|
|
114
222
|
return (
|
|
115
223
|
<div data-testid="subscription-manager" className="max-w-3xl mx-auto px-6 py-12">
|
|
116
|
-
<div className="text-
|
|
117
|
-
|
|
118
|
-
<p className="text-sm mt-1">{subError.message}</p>
|
|
224
|
+
<div data-testid="error-subscription" className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
|
225
|
+
Couldn't load your subscription. {subError.message}
|
|
119
226
|
</div>
|
|
120
227
|
</div>
|
|
121
228
|
);
|
|
122
229
|
}
|
|
123
230
|
|
|
124
|
-
|
|
125
|
-
|
|
231
|
+
// A subscription is only renderable if it carries a nested `offer` object —
|
|
232
|
+
// the cache-path payload (offerId/offerSlug, no `offer`) is normalized away in
|
|
233
|
+
// BillingApiClient, but guard here too so CurrentPlanCard can never deref
|
|
234
|
+
// `subscription.offer.*` on undefined and white-screen the page.
|
|
235
|
+
const usableSubscription = subscription?.offer ? subscription : null;
|
|
236
|
+
const currentOfferId = usableSubscription?.offer?.id ?? null;
|
|
237
|
+
const currentOffer = usableSubscription?.offer ?? null;
|
|
238
|
+
// Show ALL offers in the Change Plan grid (not just non-current ones) so users
|
|
239
|
+
// can compare and see what they have. The card matching their current sub
|
|
240
|
+
// gets a "Current Plan" badge + disabled CTA. startsim-1f1.
|
|
241
|
+
const allOffers = product?.offers ?? [];
|
|
242
|
+
|
|
243
|
+
/** Per-card direction relative to the current subscription:
|
|
244
|
+
* - "current" → user is on this exact offer → "Current Plan" badge, no action
|
|
245
|
+
* - "upgrade" → card is a higher tier (paid vs current free, or higher sortOrder)
|
|
246
|
+
* - "downgrade" → card is a lower tier (free when current is paid, or lower sortOrder)
|
|
247
|
+
* startsim-21f. */
|
|
248
|
+
type Direction = "current" | "upgrade" | "downgrade";
|
|
249
|
+
function directionFor(cardOffer: ProductOffer): Direction {
|
|
250
|
+
if (!currentOffer) return "upgrade"; // legacy: no sub → every card is an upgrade
|
|
251
|
+
if (cardOffer.id === currentOffer.id) return "current";
|
|
252
|
+
// Free vs Paid: paid is always an upgrade, free is always a downgrade.
|
|
253
|
+
if (cardOffer.isFree && !currentOffer.isFree) return "downgrade";
|
|
254
|
+
if (!cardOffer.isFree && currentOffer.isFree) return "upgrade";
|
|
255
|
+
// Both paid → compare sortOrder (apps order their tiers low → high).
|
|
256
|
+
return cardOffer.sortOrder > currentOffer.sortOrder ? "upgrade" : "downgrade";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Visible CTA label for the card, branching on direction. The default
|
|
260
|
+
* `offer.ctaText` (e.g. "Start Free Trial") is for UPGRADE only — using it
|
|
261
|
+
* on a Free card when a Pro user is looking is the bug startsim-21f closes. */
|
|
262
|
+
function ctaLabelFor(cardOffer: ProductOffer, direction: Direction): string {
|
|
263
|
+
if (direction === "current") return "Current Plan";
|
|
264
|
+
if (direction === "downgrade") return `Downgrade to ${cardOffer.name}`;
|
|
265
|
+
return cardOffer.ctaText || `Upgrade to ${cardOffer.name}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Branch the offer CTA internally so apps don't reinvent it:
|
|
269
|
+
// - Free offers → POST /billing/subscribe-free/ then refetch subscription
|
|
270
|
+
// (no Stripe round-trip needed for $0 plans — startsim-6vk).
|
|
271
|
+
// - Paid offers → app-supplied onPlanChange (typically a Stripe checkout
|
|
272
|
+
// redirect via useCheckout).
|
|
273
|
+
const handleOfferClick = async (offer: ProductOffer) => {
|
|
274
|
+
// Non-owner viewers can SEE the plans but can't change them — startsim-wo9.
|
|
275
|
+
if (subscription?.viewerIsOwner === false) return;
|
|
276
|
+
if (offer.isFree) {
|
|
277
|
+
try {
|
|
278
|
+
await subscribeFree(offer.id);
|
|
279
|
+
await refetchSubscription();
|
|
280
|
+
} catch {
|
|
281
|
+
/* Error captured by hook state — banner not added here yet. */
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
onPlanChange?.(offer);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// The "Upgrade" CTA on the current-plan card points at the NEXT tier above
|
|
289
|
+
// the user's current sub. With a current sub we ONLY consider strictly
|
|
290
|
+
// higher tiers — never the user's current plan or below. startsim-y34.
|
|
291
|
+
// Without a sub (legacy / unauthenticated preview) we fall back to the
|
|
292
|
+
// featured paid plan or the first paid plan. Returns null when the user is
|
|
293
|
+
// already on the top tier OR the product has no paid offer at all (free-
|
|
294
|
+
// only apps), so the Upgrade CTA simply doesn't render — startsim-ft2.
|
|
295
|
+
const featuredPaidOffer: ProductOffer | null = (() => {
|
|
296
|
+
if (!product) return null;
|
|
297
|
+
if (currentOffer) {
|
|
298
|
+
const above = product.offers.filter((o) => !o.isFree && o.sortOrder > currentOffer.sortOrder);
|
|
299
|
+
return above.find((o) => o.isFeatured) ?? above[0] ?? null;
|
|
300
|
+
}
|
|
301
|
+
return (
|
|
302
|
+
product.offers.find((o) => !o.isFree && o.isFeatured) ??
|
|
303
|
+
product.offers.find((o) => !o.isFree) ??
|
|
304
|
+
null
|
|
305
|
+
);
|
|
306
|
+
})();
|
|
126
307
|
|
|
127
308
|
const handlePortalClick = async () => {
|
|
128
309
|
try {
|
|
@@ -163,37 +344,41 @@ export function SubscriptionManager({
|
|
|
163
344
|
</div>
|
|
164
345
|
)}
|
|
165
346
|
|
|
166
|
-
{
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<span aria-hidden="true" className="text-base text-gray-500 ml-1">
|
|
184
|
-
{subscription.offer.intervalDisplay}
|
|
185
|
-
</span>
|
|
186
|
-
</span>
|
|
187
|
-
)}
|
|
188
|
-
</div>
|
|
189
|
-
|
|
190
|
-
{subscription.currentPeriodStart && subscription.currentPeriodEnd && (
|
|
191
|
-
<p className="text-sm text-gray-500">
|
|
192
|
-
Billing period: {formatDate(subscription.currentPeriodStart)} to{" "}
|
|
193
|
-
{formatDate(subscription.currentPeriodEnd)}
|
|
194
|
-
</p>
|
|
347
|
+
{usableSubscription ? (
|
|
348
|
+
<>
|
|
349
|
+
{/* Non-owner team members get a clear read-only callout. The OWNER
|
|
350
|
+
gates that follow (Upgrade CTA, portal button, plan-change CTAs)
|
|
351
|
+
all flip off when viewerIsOwner === false. startsim-wo9. */}
|
|
352
|
+
{usableSubscription.viewerIsOwner === false && (
|
|
353
|
+
<div
|
|
354
|
+
data-testid="non-owner-billing-notice"
|
|
355
|
+
className="rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900"
|
|
356
|
+
role="status"
|
|
357
|
+
>
|
|
358
|
+
You're viewing your team's billing.{" "}
|
|
359
|
+
{usableSubscription.ownerEmail
|
|
360
|
+
? <>The team owner <span className="font-medium">{usableSubscription.ownerEmail}</span> manages plan changes.</>
|
|
361
|
+
: <>The team owner manages plan changes.</>
|
|
362
|
+
}
|
|
363
|
+
</div>
|
|
195
364
|
)}
|
|
196
|
-
|
|
365
|
+
<CurrentPlanCard
|
|
366
|
+
subscription={usableSubscription}
|
|
367
|
+
usage={usage}
|
|
368
|
+
onPortalClick={usableSubscription.viewerIsOwner === false ? undefined : handlePortalClick}
|
|
369
|
+
upgradeCta={
|
|
370
|
+
usableSubscription.viewerIsOwner !== false && usableSubscription.offer.isFree && featuredPaidOffer ? (
|
|
371
|
+
<button
|
|
372
|
+
data-testid="upgrade-cta-current-plan"
|
|
373
|
+
onClick={() => handleOfferClick(featuredPaidOffer)}
|
|
374
|
+
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-bold rounded-xl bg-blue-600 text-white hover:bg-blue-700 cursor-pointer transition-colors"
|
|
375
|
+
>
|
|
376
|
+
Upgrade to {featuredPaidOffer.name} →
|
|
377
|
+
</button>
|
|
378
|
+
) : null
|
|
379
|
+
}
|
|
380
|
+
/>
|
|
381
|
+
</>
|
|
197
382
|
) : (
|
|
198
383
|
<div className="text-center py-8">
|
|
199
384
|
<p className="text-lg text-gray-600">No active subscription</p>
|
|
@@ -201,56 +386,127 @@ export function SubscriptionManager({
|
|
|
201
386
|
</div>
|
|
202
387
|
)}
|
|
203
388
|
|
|
204
|
-
{
|
|
389
|
+
{/* Product loading while sub already rendered: show plan-grid skeletons
|
|
390
|
+
rather than empty space — startsim-mtv. */}
|
|
391
|
+
{productLoading && (
|
|
392
|
+
<div data-testid="available-plans-loading" className="space-y-4">
|
|
393
|
+
<h3 className="text-lg font-semibold text-gray-900">
|
|
394
|
+
{subscription ? "Change Plan" : "Available Plans"}
|
|
395
|
+
</h3>
|
|
396
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
397
|
+
<PlanSkeleton testId="skeleton-plan-card" />
|
|
398
|
+
<PlanSkeleton testId="skeleton-plan-card" />
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
{/* Product fetch error doesn't blank the page — startsim-c0r. Sub still
|
|
404
|
+
renders above; plans section becomes a recoverable inline error. */}
|
|
405
|
+
{!productLoading && productError && (
|
|
406
|
+
<div data-testid="error-product" className="rounded-xl border border-red-200 bg-red-50 p-4 flex items-center justify-between gap-3">
|
|
407
|
+
<p className="text-sm text-red-800">
|
|
408
|
+
Couldn't load plans. {productError.message}
|
|
409
|
+
</p>
|
|
410
|
+
<button
|
|
411
|
+
data-testid="retry-product"
|
|
412
|
+
onClick={() => refetchProduct()}
|
|
413
|
+
className="shrink-0 text-sm font-semibold px-3 py-1.5 rounded-lg bg-red-600 text-white hover:bg-red-700 cursor-pointer"
|
|
414
|
+
>
|
|
415
|
+
Retry
|
|
416
|
+
</button>
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
|
|
420
|
+
{/* Only render the Change Plan section when there are at least 2 offers
|
|
421
|
+
to show. Single-tier apps (just Free) skip it entirely — startsim-ft2. */}
|
|
422
|
+
{!productLoading && !productError && allOffers.length >= 2 && (
|
|
205
423
|
<div data-testid="available-plans" className="space-y-4">
|
|
206
424
|
<h3 className="text-lg font-semibold text-gray-900">
|
|
207
425
|
{subscription ? "Change Plan" : "Available Plans"}
|
|
208
426
|
</h3>
|
|
209
427
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
210
|
-
{
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
428
|
+
{allOffers.map((offer) => {
|
|
429
|
+
const direction = directionFor(offer);
|
|
430
|
+
const isCurrent = direction === "current";
|
|
431
|
+
const isDowngrade = direction === "downgrade";
|
|
432
|
+
const ctaLabel = ctaLabelFor(offer, direction);
|
|
433
|
+
|
|
434
|
+
// Border: current = solid ring; featured upgrade = blue; else gray.
|
|
435
|
+
const cardBorder =
|
|
436
|
+
isCurrent ? "border-2 border-primary-600 ring-2 ring-primary-100 bg-white shadow-md"
|
|
437
|
+
: offer.isFeatured ? "border border-blue-600 shadow-md bg-white"
|
|
438
|
+
: "border border-gray-200 bg-white shadow-sm";
|
|
439
|
+
|
|
440
|
+
// CTA: current = disabled badge; downgrade = secondary gray;
|
|
441
|
+
// upgrade = blue (featured) or gray (non-featured).
|
|
442
|
+
const ctaClass =
|
|
443
|
+
isCurrent ? "bg-gray-100 text-gray-500 cursor-not-allowed"
|
|
444
|
+
: isDowngrade ? "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 cursor-pointer"
|
|
445
|
+
: offer.isFeatured ? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
|
|
446
|
+
: "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer";
|
|
447
|
+
|
|
448
|
+
const isViewerNonOwner = subscription?.viewerIsOwner === false;
|
|
449
|
+
|
|
450
|
+
return (
|
|
451
|
+
<div
|
|
452
|
+
key={offer.id}
|
|
453
|
+
data-testid={`plan-card-${offer.slug}`}
|
|
454
|
+
data-direction={direction}
|
|
455
|
+
className={`relative rounded-xl p-6 transition-colors ${cardBorder}`}
|
|
456
|
+
>
|
|
457
|
+
{isCurrent && (
|
|
458
|
+
<span
|
|
459
|
+
data-testid={`current-plan-badge-${offer.slug}`}
|
|
460
|
+
className="absolute -top-2.5 right-4 inline-block bg-primary-600 text-white text-xs font-bold uppercase tracking-wide px-2.5 py-0.5 rounded-full"
|
|
461
|
+
>
|
|
462
|
+
Current Plan
|
|
224
463
|
</span>
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
464
|
+
)}
|
|
465
|
+
<h4 className="text-lg font-bold text-gray-900 mb-2">{offer.name}</h4>
|
|
466
|
+
<div className="mb-3">
|
|
467
|
+
{offer.isFree ? (
|
|
468
|
+
<span aria-label="Free">
|
|
469
|
+
<span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">Free</span>
|
|
229
470
|
</span>
|
|
230
|
-
|
|
231
|
-
|
|
471
|
+
) : (
|
|
472
|
+
<span aria-label={`${Math.floor(parseFloat(offer.unitPrice) || 0)} dollars ${offer.intervalDisplay}`}>
|
|
473
|
+
<span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">
|
|
474
|
+
${Math.floor(parseFloat(offer.unitPrice) || 0)}
|
|
475
|
+
</span>
|
|
476
|
+
<span aria-hidden="true" className="text-sm text-gray-500 ml-1">
|
|
477
|
+
{offer.intervalDisplay}
|
|
478
|
+
</span>
|
|
232
479
|
</span>
|
|
233
|
-
|
|
234
|
-
|
|
480
|
+
)}
|
|
481
|
+
</div>
|
|
482
|
+
{/* Trial copy is for prospective trialers only — once the
|
|
483
|
+
user is on this offer or moving downward, drop the
|
|
484
|
+
"14-day free trial" sales pitch. */}
|
|
485
|
+
{direction === "upgrade" && <TrialCopy trialDays={offer.trialDays} />}
|
|
486
|
+
<PlanFeatureList features={offer.features} />
|
|
487
|
+
<button
|
|
488
|
+
data-testid={`upgrade-cta-${offer.slug}`}
|
|
489
|
+
onClick={() => !isCurrent && !isViewerNonOwner && handleOfferClick(offer)}
|
|
490
|
+
disabled={isCurrent || isViewerNonOwner}
|
|
491
|
+
aria-disabled={isCurrent || isViewerNonOwner || undefined}
|
|
492
|
+
title={isViewerNonOwner ? "Only the team owner can change the plan" : undefined}
|
|
493
|
+
className={`w-full py-2.5 px-4 text-sm font-bold rounded-xl transition-colors ${ctaClass} ${isViewerNonOwner ? "opacity-60 cursor-not-allowed" : ""}`}
|
|
494
|
+
>
|
|
495
|
+
{ctaLabel}
|
|
496
|
+
</button>
|
|
235
497
|
</div>
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
onClick={() => onPlanChange?.(offer)}
|
|
239
|
-
className={`w-full py-2.5 px-4 text-sm font-bold rounded-xl cursor-pointer transition-colors ${
|
|
240
|
-
offer.isFeatured
|
|
241
|
-
? "bg-blue-600 text-white hover:bg-blue-700"
|
|
242
|
-
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
|
243
|
-
}`}
|
|
244
|
-
>
|
|
245
|
-
{offer.ctaText}
|
|
246
|
-
</button>
|
|
247
|
-
</div>
|
|
248
|
-
))}
|
|
498
|
+
);
|
|
499
|
+
})}
|
|
249
500
|
</div>
|
|
250
501
|
</div>
|
|
251
502
|
)}
|
|
252
503
|
|
|
253
|
-
{
|
|
504
|
+
{/* Manage-billing only when there's a real provider sub AND the viewer
|
|
505
|
+
is the team owner (non-owners get a read-only view — startsim-wo9).
|
|
506
|
+
Free-tier users (provider='none') have no Stripe customer, so the
|
|
507
|
+
portal call would 404 — they get the Upgrade CTA on the current-plan
|
|
508
|
+
card instead. startsim-b1z. */}
|
|
509
|
+
{usableSubscription && usableSubscription.status !== "cancelled" && !usableSubscription.offer.isFree && usableSubscription.viewerIsOwner !== false && (
|
|
254
510
|
<div className="pt-4 border-t border-gray-100">
|
|
255
511
|
<button
|
|
256
512
|
data-testid="portal-button"
|
package/src/components/index.ts
CHANGED
|
@@ -12,3 +12,7 @@ export { SubscriptionManager } from "./SubscriptionManager";
|
|
|
12
12
|
export type { SubscriptionManagerProps } from "./SubscriptionManager";
|
|
13
13
|
export { PlanGate } from "./PlanGate";
|
|
14
14
|
export type { PlanGateProps } from "./PlanGate";
|
|
15
|
+
export { CurrentPlanCard } from "./CurrentPlanCard";
|
|
16
|
+
export type { CurrentPlanCardProps } from "./CurrentPlanCard";
|
|
17
|
+
export { LimitNudge } from "./LimitNudge";
|
|
18
|
+
export type { LimitNudgeProps } from "./LimitNudge";
|
package/src/hooks/index.ts
CHANGED
|
@@ -5,3 +5,4 @@ export { useCheckout } from "./useCheckout";
|
|
|
5
5
|
export { usePortal } from "./usePortal";
|
|
6
6
|
export { useSubscription } from "./useSubscription";
|
|
7
7
|
export { useSuccessSync } from "./useSuccessSync";
|
|
8
|
+
export { useUsage, notifyUsageChanged, USAGE_CHANGED_EVENT } from "./useUsage";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useUsage — Fetch per-product usage (startsim-h14).
|
|
3
|
+
*
|
|
4
|
+
* Reads from GET /api/v1/billing/usage/<slug>/ via the BillingProvider's
|
|
5
|
+
* configured fetcher + apiBaseUrl. The backend returns an empty object
|
|
6
|
+
* for any failure mode (no adapter, no subscription, adapter exception),
|
|
7
|
+
* so consumers must treat {} as "hide usage" rather than as an error.
|
|
8
|
+
*
|
|
9
|
+
* Re-fetches automatically:
|
|
10
|
+
* - on mount / when slug changes
|
|
11
|
+
* - when the tab regains visibility (`visibilitychange` event)
|
|
12
|
+
* - when ANY code in the app dispatches a `billing:usage-changed`
|
|
13
|
+
* CustomEvent on `window` — apps signal mutations that altered
|
|
14
|
+
* usage (env create, env delete, secret create…) by calling
|
|
15
|
+
* `notifyUsageChanged()` (exported below). This keeps usage live
|
|
16
|
+
* without coupling useUsage to React Query or every app's data layer.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* const { usage, loading, error, refetch } = useUsage("vault");
|
|
20
|
+
* const envs = usage?.environments; // { used, limit } | undefined
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useCallback, useEffect, useState } from "react";
|
|
24
|
+
import type { UsageMap } from "../types";
|
|
25
|
+
import { useBillingContext } from "./BillingProvider";
|
|
26
|
+
|
|
27
|
+
interface UseUsageResult {
|
|
28
|
+
usage: UsageMap;
|
|
29
|
+
loading: boolean;
|
|
30
|
+
error: Error | null;
|
|
31
|
+
refetch: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Event name apps fire (and useUsage listens for) when something happened
|
|
35
|
+
* that could change usage counts — env created, secret deleted, etc. */
|
|
36
|
+
export const USAGE_CHANGED_EVENT = "billing:usage-changed";
|
|
37
|
+
|
|
38
|
+
/** Helper apps use to nudge every mounted useUsage hook to refetch.
|
|
39
|
+
* Safe to call from anywhere (no-ops outside the browser). */
|
|
40
|
+
export function notifyUsageChanged(slug?: string): void {
|
|
41
|
+
if (typeof window === "undefined") return;
|
|
42
|
+
window.dispatchEvent(new CustomEvent(USAGE_CHANGED_EVENT, { detail: { slug } }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useUsage(slug: string): UseUsageResult {
|
|
46
|
+
const { client } = useBillingContext();
|
|
47
|
+
const [usage, setUsage] = useState<UsageMap>({});
|
|
48
|
+
const [loading, setLoading] = useState(true);
|
|
49
|
+
const [error, setError] = useState<Error | null>(null);
|
|
50
|
+
|
|
51
|
+
const fetchUsage = useCallback(async () => {
|
|
52
|
+
if (!slug) {
|
|
53
|
+
setUsage({});
|
|
54
|
+
setLoading(false);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
setLoading(true);
|
|
58
|
+
setError(null);
|
|
59
|
+
try {
|
|
60
|
+
const data = await client.getUsage(slug);
|
|
61
|
+
setUsage(data ?? {});
|
|
62
|
+
} catch (e) {
|
|
63
|
+
// Usage MUST NOT break the page — record the error but still render {}.
|
|
64
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
65
|
+
setUsage({});
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false);
|
|
68
|
+
}
|
|
69
|
+
}, [client, slug]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
fetchUsage();
|
|
73
|
+
}, [fetchUsage]);
|
|
74
|
+
|
|
75
|
+
// Stay live: refetch on tab focus + on app-dispatched change events.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (typeof window === "undefined") return;
|
|
78
|
+
const onVis = () => {
|
|
79
|
+
if (document.visibilityState === "visible") fetchUsage();
|
|
80
|
+
};
|
|
81
|
+
const onChange = (ev: Event) => {
|
|
82
|
+
const target = (ev as CustomEvent<{ slug?: string }>).detail?.slug;
|
|
83
|
+
if (!target || target === slug) fetchUsage();
|
|
84
|
+
};
|
|
85
|
+
document.addEventListener("visibilitychange", onVis);
|
|
86
|
+
window.addEventListener(USAGE_CHANGED_EVENT, onChange);
|
|
87
|
+
return () => {
|
|
88
|
+
document.removeEventListener("visibilitychange", onVis);
|
|
89
|
+
window.removeEventListener(USAGE_CHANGED_EVENT, onChange);
|
|
90
|
+
};
|
|
91
|
+
}, [fetchUsage, slug]);
|
|
92
|
+
|
|
93
|
+
return { usage, loading, error, refetch: fetchUsage };
|
|
94
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,9 @@ export {
|
|
|
18
18
|
usePortal,
|
|
19
19
|
useSubscription,
|
|
20
20
|
useSuccessSync,
|
|
21
|
+
useUsage,
|
|
22
|
+
notifyUsageChanged,
|
|
23
|
+
USAGE_CHANGED_EVENT,
|
|
21
24
|
} from "./hooks";
|
|
22
25
|
export type { BillingProviderProps } from "./hooks";
|
|
23
26
|
|
|
@@ -33,10 +36,11 @@ export type {
|
|
|
33
36
|
PaymentMethod,
|
|
34
37
|
PortalResult,
|
|
35
38
|
SubscriptionInfo,
|
|
39
|
+
UsageMap,
|
|
36
40
|
} from "./types";
|
|
37
41
|
|
|
38
42
|
// Components
|
|
39
|
-
export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager, PlanGate } from "./components";
|
|
43
|
+
export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager, PlanGate, CurrentPlanCard, LimitNudge } from "./components";
|
|
40
44
|
export type {
|
|
41
45
|
PricingPageProps,
|
|
42
46
|
PricingSectionProps,
|
|
@@ -45,6 +49,8 @@ export type {
|
|
|
45
49
|
ManageSubscriptionProps,
|
|
46
50
|
SubscriptionManagerProps,
|
|
47
51
|
PlanGateProps,
|
|
52
|
+
CurrentPlanCardProps,
|
|
53
|
+
LimitNudgeProps,
|
|
48
54
|
} from "./components";
|
|
49
55
|
|
|
50
56
|
// API client (for advanced usage)
|