@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.
@@ -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 type { ProductOffer } from "../types";
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
- const loading = subLoading || productLoading;
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
- if (loading) {
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
- <p className="text-center text-gray-500">Loading subscription...</p>
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
- if (subError) {
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-center text-red-600">
117
- <p className="font-semibold">Something went wrong</p>
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&apos;t load your subscription. {subError.message}
119
226
  </div>
120
227
  </div>
121
228
  );
122
229
  }
123
230
 
124
- const currentOfferId = subscription?.offer?.id ?? null;
125
- const availableOffers = product?.offers.filter((o) => o.id !== currentOfferId) ?? [];
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
- {subscription ? (
167
- <div data-testid="current-plan" className="rounded-2xl border border-gray-200 bg-white shadow-sm p-8">
168
- <div className="flex items-center justify-between mb-4">
169
- <h2 className="text-xl font-bold text-gray-900">{subscription.offer.name}</h2>
170
- <StatusBadge status={subscription.status} trialEnd={subscription.trialEnd} />
171
- </div>
172
-
173
- <div className="mb-4">
174
- {subscription.offer.isFree ? (
175
- <span aria-label="Free">
176
- <span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">Free</span>
177
- </span>
178
- ) : (
179
- <span aria-label={`${Math.floor(parseFloat(subscription.offer.unitPrice) || 0)} dollars ${subscription.offer.intervalDisplay}`}>
180
- <span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">
181
- ${Math.floor(parseFloat(subscription.offer.unitPrice) || 0)}
182
- </span>
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&apos;re viewing your team&apos;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
- </div>
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
- {availableOffers.length > 0 && (
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&apos;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
- {availableOffers.map((offer) => (
211
- <div
212
- key={offer.id}
213
- className={`rounded-xl border p-6 ${
214
- offer.isFeatured
215
- ? "border-blue-600 shadow-md"
216
- : "border-gray-200 bg-white shadow-sm"
217
- }`}
218
- >
219
- <h4 className="text-lg font-bold text-gray-900 mb-2">{offer.name}</h4>
220
- <div className="mb-3">
221
- {offer.isFree ? (
222
- <span aria-label="Free">
223
- <span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">Free</span>
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
- <span aria-label={`${Math.floor(parseFloat(offer.unitPrice) || 0)} dollars ${offer.intervalDisplay}`}>
227
- <span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">
228
- ${Math.floor(parseFloat(offer.unitPrice) || 0)}
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
- <span aria-hidden="true" className="text-sm text-gray-500 ml-1">
231
- {offer.intervalDisplay}
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
- </span>
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
- <button
237
- data-testid={`upgrade-cta-${offer.slug}`}
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
- {subscription && subscription.status !== "cancelled" && (
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"
@@ -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";
@@ -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)