@startsimpli/billing 0.4.12 → 0.4.14

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,133 @@ 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
231
  const currentOfferId = subscription?.offer?.id ?? null;
125
- const availableOffers = product?.offers.filter((o) => o.id !== currentOfferId) ?? [];
232
+ const currentOffer = subscription?.offer ?? null;
233
+ // Show ALL offers in the Change Plan grid (not just non-current ones) so users
234
+ // can compare and see what they have. The card matching their current sub
235
+ // gets a "Current Plan" badge + disabled CTA. startsim-1f1.
236
+ const allOffers = product?.offers ?? [];
237
+
238
+ /** Per-card direction relative to the current subscription:
239
+ * - "current" → user is on this exact offer → "Current Plan" badge, no action
240
+ * - "upgrade" → card is a higher tier (paid vs current free, or higher sortOrder)
241
+ * - "downgrade" → card is a lower tier (free when current is paid, or lower sortOrder)
242
+ * startsim-21f. */
243
+ type Direction = "current" | "upgrade" | "downgrade";
244
+ function directionFor(cardOffer: ProductOffer): Direction {
245
+ if (!currentOffer) return "upgrade"; // legacy: no sub → every card is an upgrade
246
+ if (cardOffer.id === currentOffer.id) return "current";
247
+ // Free vs Paid: paid is always an upgrade, free is always a downgrade.
248
+ if (cardOffer.isFree && !currentOffer.isFree) return "downgrade";
249
+ if (!cardOffer.isFree && currentOffer.isFree) return "upgrade";
250
+ // Both paid → compare sortOrder (apps order their tiers low → high).
251
+ return cardOffer.sortOrder > currentOffer.sortOrder ? "upgrade" : "downgrade";
252
+ }
253
+
254
+ /** Visible CTA label for the card, branching on direction. The default
255
+ * `offer.ctaText` (e.g. "Start Free Trial") is for UPGRADE only — using it
256
+ * on a Free card when a Pro user is looking is the bug startsim-21f closes. */
257
+ function ctaLabelFor(cardOffer: ProductOffer, direction: Direction): string {
258
+ if (direction === "current") return "Current Plan";
259
+ if (direction === "downgrade") return `Downgrade to ${cardOffer.name}`;
260
+ return cardOffer.ctaText || `Upgrade to ${cardOffer.name}`;
261
+ }
262
+
263
+ // Branch the offer CTA internally so apps don't reinvent it:
264
+ // - Free offers → POST /billing/subscribe-free/ then refetch subscription
265
+ // (no Stripe round-trip needed for $0 plans — startsim-6vk).
266
+ // - Paid offers → app-supplied onPlanChange (typically a Stripe checkout
267
+ // redirect via useCheckout).
268
+ const handleOfferClick = async (offer: ProductOffer) => {
269
+ // Non-owner viewers can SEE the plans but can't change them — startsim-wo9.
270
+ if (subscription?.viewerIsOwner === false) return;
271
+ if (offer.isFree) {
272
+ try {
273
+ await subscribeFree(offer.id);
274
+ await refetchSubscription();
275
+ } catch {
276
+ /* Error captured by hook state — banner not added here yet. */
277
+ }
278
+ return;
279
+ }
280
+ onPlanChange?.(offer);
281
+ };
282
+
283
+ // The "Upgrade" CTA on the current-plan card points at the NEXT tier above
284
+ // the user's current sub. With a current sub we ONLY consider strictly
285
+ // higher tiers — never the user's current plan or below. startsim-y34.
286
+ // Without a sub (legacy / unauthenticated preview) we fall back to the
287
+ // featured paid plan or the first paid plan. Returns null when the user is
288
+ // already on the top tier OR the product has no paid offer at all (free-
289
+ // only apps), so the Upgrade CTA simply doesn't render — startsim-ft2.
290
+ const featuredPaidOffer: ProductOffer | null = (() => {
291
+ if (!product) return null;
292
+ if (currentOffer) {
293
+ const above = product.offers.filter((o) => !o.isFree && o.sortOrder > currentOffer.sortOrder);
294
+ return above.find((o) => o.isFeatured) ?? above[0] ?? null;
295
+ }
296
+ return (
297
+ product.offers.find((o) => !o.isFree && o.isFeatured) ??
298
+ product.offers.find((o) => !o.isFree) ??
299
+ null
300
+ );
301
+ })();
126
302
 
127
303
  const handlePortalClick = async () => {
128
304
  try {
@@ -164,36 +340,40 @@ export function SubscriptionManager({
164
340
  )}
165
341
 
166
342
  {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>
343
+ <>
344
+ {/* Non-owner team members get a clear read-only callout. The OWNER
345
+ gates that follow (Upgrade CTA, portal button, plan-change CTAs)
346
+ all flip off when viewerIsOwner === false. startsim-wo9. */}
347
+ {subscription.viewerIsOwner === false && (
348
+ <div
349
+ data-testid="non-owner-billing-notice"
350
+ className="rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900"
351
+ role="status"
352
+ >
353
+ You&apos;re viewing your team&apos;s billing.{" "}
354
+ {subscription.ownerEmail
355
+ ? <>The team owner <span className="font-medium">{subscription.ownerEmail}</span> manages plan changes.</>
356
+ : <>The team owner manages plan changes.</>
357
+ }
358
+ </div>
195
359
  )}
196
- </div>
360
+ <CurrentPlanCard
361
+ subscription={subscription}
362
+ usage={usage}
363
+ onPortalClick={subscription.viewerIsOwner === false ? undefined : handlePortalClick}
364
+ upgradeCta={
365
+ subscription.viewerIsOwner !== false && subscription.offer.isFree && featuredPaidOffer ? (
366
+ <button
367
+ data-testid="upgrade-cta-current-plan"
368
+ onClick={() => handleOfferClick(featuredPaidOffer)}
369
+ 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"
370
+ >
371
+ Upgrade to {featuredPaidOffer.name} →
372
+ </button>
373
+ ) : null
374
+ }
375
+ />
376
+ </>
197
377
  ) : (
198
378
  <div className="text-center py-8">
199
379
  <p className="text-lg text-gray-600">No active subscription</p>
@@ -201,56 +381,127 @@ export function SubscriptionManager({
201
381
  </div>
202
382
  )}
203
383
 
204
- {availableOffers.length > 0 && (
384
+ {/* Product loading while sub already rendered: show plan-grid skeletons
385
+ rather than empty space — startsim-mtv. */}
386
+ {productLoading && (
387
+ <div data-testid="available-plans-loading" className="space-y-4">
388
+ <h3 className="text-lg font-semibold text-gray-900">
389
+ {subscription ? "Change Plan" : "Available Plans"}
390
+ </h3>
391
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
392
+ <PlanSkeleton testId="skeleton-plan-card" />
393
+ <PlanSkeleton testId="skeleton-plan-card" />
394
+ </div>
395
+ </div>
396
+ )}
397
+
398
+ {/* Product fetch error doesn't blank the page — startsim-c0r. Sub still
399
+ renders above; plans section becomes a recoverable inline error. */}
400
+ {!productLoading && productError && (
401
+ <div data-testid="error-product" className="rounded-xl border border-red-200 bg-red-50 p-4 flex items-center justify-between gap-3">
402
+ <p className="text-sm text-red-800">
403
+ Couldn&apos;t load plans. {productError.message}
404
+ </p>
405
+ <button
406
+ data-testid="retry-product"
407
+ onClick={() => refetchProduct()}
408
+ 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"
409
+ >
410
+ Retry
411
+ </button>
412
+ </div>
413
+ )}
414
+
415
+ {/* Only render the Change Plan section when there are at least 2 offers
416
+ to show. Single-tier apps (just Free) skip it entirely — startsim-ft2. */}
417
+ {!productLoading && !productError && allOffers.length >= 2 && (
205
418
  <div data-testid="available-plans" className="space-y-4">
206
419
  <h3 className="text-lg font-semibold text-gray-900">
207
420
  {subscription ? "Change Plan" : "Available Plans"}
208
421
  </h3>
209
422
  <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>
423
+ {allOffers.map((offer) => {
424
+ const direction = directionFor(offer);
425
+ const isCurrent = direction === "current";
426
+ const isDowngrade = direction === "downgrade";
427
+ const ctaLabel = ctaLabelFor(offer, direction);
428
+
429
+ // Border: current = solid ring; featured upgrade = blue; else gray.
430
+ const cardBorder =
431
+ isCurrent ? "border-2 border-primary-600 ring-2 ring-primary-100 bg-white shadow-md"
432
+ : offer.isFeatured ? "border border-blue-600 shadow-md bg-white"
433
+ : "border border-gray-200 bg-white shadow-sm";
434
+
435
+ // CTA: current = disabled badge; downgrade = secondary gray;
436
+ // upgrade = blue (featured) or gray (non-featured).
437
+ const ctaClass =
438
+ isCurrent ? "bg-gray-100 text-gray-500 cursor-not-allowed"
439
+ : isDowngrade ? "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 cursor-pointer"
440
+ : offer.isFeatured ? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
441
+ : "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer";
442
+
443
+ const isViewerNonOwner = subscription?.viewerIsOwner === false;
444
+
445
+ return (
446
+ <div
447
+ key={offer.id}
448
+ data-testid={`plan-card-${offer.slug}`}
449
+ data-direction={direction}
450
+ className={`relative rounded-xl p-6 transition-colors ${cardBorder}`}
451
+ >
452
+ {isCurrent && (
453
+ <span
454
+ data-testid={`current-plan-badge-${offer.slug}`}
455
+ 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"
456
+ >
457
+ Current Plan
224
458
  </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)}
459
+ )}
460
+ <h4 className="text-lg font-bold text-gray-900 mb-2">{offer.name}</h4>
461
+ <div className="mb-3">
462
+ {offer.isFree ? (
463
+ <span aria-label="Free">
464
+ <span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">Free</span>
229
465
  </span>
230
- <span aria-hidden="true" className="text-sm text-gray-500 ml-1">
231
- {offer.intervalDisplay}
466
+ ) : (
467
+ <span aria-label={`${Math.floor(parseFloat(offer.unitPrice) || 0)} dollars ${offer.intervalDisplay}`}>
468
+ <span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">
469
+ ${Math.floor(parseFloat(offer.unitPrice) || 0)}
470
+ </span>
471
+ <span aria-hidden="true" className="text-sm text-gray-500 ml-1">
472
+ {offer.intervalDisplay}
473
+ </span>
232
474
  </span>
233
- </span>
234
- )}
475
+ )}
476
+ </div>
477
+ {/* Trial copy is for prospective trialers only — once the
478
+ user is on this offer or moving downward, drop the
479
+ "14-day free trial" sales pitch. */}
480
+ {direction === "upgrade" && <TrialCopy trialDays={offer.trialDays} />}
481
+ <PlanFeatureList features={offer.features} />
482
+ <button
483
+ data-testid={`upgrade-cta-${offer.slug}`}
484
+ onClick={() => !isCurrent && !isViewerNonOwner && handleOfferClick(offer)}
485
+ disabled={isCurrent || isViewerNonOwner}
486
+ aria-disabled={isCurrent || isViewerNonOwner || undefined}
487
+ title={isViewerNonOwner ? "Only the team owner can change the plan" : undefined}
488
+ className={`w-full py-2.5 px-4 text-sm font-bold rounded-xl transition-colors ${ctaClass} ${isViewerNonOwner ? "opacity-60 cursor-not-allowed" : ""}`}
489
+ >
490
+ {ctaLabel}
491
+ </button>
235
492
  </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
- ))}
493
+ );
494
+ })}
249
495
  </div>
250
496
  </div>
251
497
  )}
252
498
 
253
- {subscription && subscription.status !== "cancelled" && (
499
+ {/* Manage-billing only when there's a real provider sub AND the viewer
500
+ is the team owner (non-owners get a read-only view — startsim-wo9).
501
+ Free-tier users (provider='none') have no Stripe customer, so the
502
+ portal call would 404 — they get the Upgrade CTA on the current-plan
503
+ card instead. startsim-b1z. */}
504
+ {subscription && subscription.status !== "cancelled" && !subscription.offer.isFree && subscription.viewerIsOwner !== false && (
254
505
  <div className="pt-4 border-t border-gray-100">
255
506
  <button
256
507
  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)