@startsimpli/billing 0.4.23 → 0.4.24

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.
@@ -21,6 +21,8 @@ import { useUsage } from "../hooks/useUsage";
21
21
  import { useSuccessSync } from "../hooks/useSuccessSync";
22
22
  import type { OfferFeature, ProductOffer } from "../types";
23
23
  import { CurrentPlanCard } from "./CurrentPlanCard";
24
+ import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers } from "./IntervalToggle";
25
+ import { formatFeatureLabel } from "../utils/format";
24
26
 
25
27
  export interface SubscriptionManagerProps {
26
28
  /** Product slug (e.g., "raise-simpli") */
@@ -41,16 +43,8 @@ export interface SubscriptionManagerProps {
41
43
  * "Unlimited X" (-1) / "Up to N X" / "X" (no limit attribute).
42
44
  * startsim-o7u — kills the blank gap between price and CTA on plan cards. */
43
45
  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
- }
46
+ if (feature.value === false) return null;
47
+ const label = formatFeatureLabel(feature);
54
48
 
55
49
  return (
56
50
  <li className="flex items-start gap-2 text-sm text-gray-700">
@@ -73,12 +67,14 @@ function PlanFeatureList({ features }: { features: OfferFeature[] }) {
73
67
  }
74
68
 
75
69
  /** 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. */
70
+ * startsim-pih — so users know "Start Free Trial" actually IS a free trial.
71
+ * No "no card required": checkout collects a card up front and the offer
72
+ * carries nothing that says otherwise (crochet_patterns-34n7). */
77
73
  function TrialCopy({ trialDays }: { trialDays: number }) {
78
74
  if (!trialDays || trialDays <= 0) return null;
79
75
  return (
80
76
  <p data-testid="trial-copy" className="-mt-1 mb-3 text-xs text-gray-500">
81
- {trialDays}-day free trial · no card required
77
+ {trialDays}-day free trial
82
78
  </p>
83
79
  );
84
80
  }
@@ -178,6 +174,10 @@ export function SubscriptionManager({
178
174
  const { subscribeFree } = useCheckout();
179
175
  const { usage } = useUsage(productId);
180
176
  const { sync: successSync } = useSuccessSync();
177
+ const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
178
+ product?.offers ?? [],
179
+ subscription?.offer?.id ?? null,
180
+ );
181
181
 
182
182
  // startsim-ngw — when the user lands on ?status=success (just back from
183
183
  // Stripe checkout), POST /billing/success-sync/ ONCE to populate the cache
@@ -233,22 +233,31 @@ export function SubscriptionManager({
233
233
  // BillingApiClient, but guard here too so CurrentPlanCard can never deref
234
234
  // `subscription.offer.*` on undefined and white-screen the page.
235
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 ?? [];
236
+ // No subscription row once the fetch has settled (404 → null; a signed-out
237
+ // viewer gets a 401 and the error branch above) means the free plan:
238
+ // that card reads "Current plan" and is disabled (crochet_patterns-34n7).
239
+ const freeOffer = product?.offers.find((o) => o.isFree) ?? null;
240
+ const onFreeByDefault = !usableSubscription && !subLoading && !subError && freeOffer !== null;
241
+ const currentOffer = usableSubscription?.offer ?? (onFreeByDefault ? freeOffer : null);
242
+ const tierKeyOf = (offerId: string) =>
243
+ entries.find(({ tier }) => tier.monthly?.id === offerId || tier.annual?.id === offerId)?.tier.key ?? null;
244
+ // Show every tier in the Change Plan grid (not just non-current ones) so
245
+ // users can compare and see what they have; `entries` is one offer per tier
246
+ // for the chosen interval. The card matching their current plan gets a
247
+ // "Current plan" badge + disabled CTA. startsim-1f1, crochet_patterns-34n7.
242
248
 
243
249
  /** Per-card direction relative to the current subscription:
244
250
  * - "current" → user is on this exact offer → "Current Plan" badge, no action
245
251
  * - "upgrade" → card is a higher tier (paid vs current free, or higher sortOrder)
246
252
  * - "downgrade" → card is a lower tier (free when current is paid, or lower sortOrder)
247
253
  * startsim-21f. */
248
- type Direction = "current" | "upgrade" | "downgrade";
254
+ type Direction = "current" | "upgrade" | "downgrade" | "switch";
249
255
  function directionFor(cardOffer: ProductOffer): Direction {
250
- if (!currentOffer) return "upgrade"; // legacy: no sub → every card is an upgrade
256
+ if (!currentOffer) return "upgrade"; // plan still unknown → every card is an upgrade
251
257
  if (cardOffer.id === currentOffer.id) return "current";
258
+ // Same plan, other billing interval (Lite ↔ Lite Annual).
259
+ const tierKey = tierKeyOf(cardOffer.id);
260
+ if (tierKey !== null && tierKey === tierKeyOf(currentOffer.id)) return "switch";
252
261
  // Free vs Paid: paid is always an upgrade, free is always a downgrade.
253
262
  if (cardOffer.isFree && !currentOffer.isFree) return "downgrade";
254
263
  if (!cardOffer.isFree && currentOffer.isFree) return "upgrade";
@@ -260,7 +269,8 @@ export function SubscriptionManager({
260
269
  * `offer.ctaText` (e.g. "Start Free Trial") is for UPGRADE only — using it
261
270
  * on a Free card when a Pro user is looking is the bug startsim-21f closes. */
262
271
  function ctaLabelFor(cardOffer: ProductOffer, direction: Direction): string {
263
- if (direction === "current") return "Current Plan";
272
+ if (direction === "current") return "Current plan";
273
+ if (direction === "switch") return cardOffer.intervalUnit === "year" ? "Switch to annual" : "Switch to monthly";
264
274
  if (direction === "downgrade") return `Downgrade to ${cardOffer.name}`;
265
275
  return cardOffer.ctaText || `Upgrade to ${cardOffer.name}`;
266
276
  }
@@ -379,6 +389,11 @@ export function SubscriptionManager({
379
389
  }
380
390
  />
381
391
  </>
392
+ ) : onFreeByDefault && freeOffer ? (
393
+ <div data-testid="free-by-default" className="text-center py-8">
394
+ <p className="text-lg text-gray-600">You&apos;re on the {freeOffer.name} plan</p>
395
+ <p className="text-sm text-gray-400 mt-1">Choose a plan below to upgrade.</p>
396
+ </div>
382
397
  ) : (
383
398
  <div className="text-center py-8">
384
399
  <p className="text-lg text-gray-600">No active subscription</p>
@@ -419,16 +434,18 @@ export function SubscriptionManager({
419
434
 
420
435
  {/* Only render the Change Plan section when there are at least 2 offers
421
436
  to show. Single-tier apps (just Free) skip it entirely — startsim-ft2. */}
422
- {!productLoading && !productError && allOffers.length >= 2 && (
437
+ {!productLoading && !productError && entries.length >= 2 && (
423
438
  <div data-testid="available-plans" className="space-y-4">
424
439
  <h3 className="text-lg font-semibold text-gray-900">
425
440
  {subscription ? "Change Plan" : "Available Plans"}
426
441
  </h3>
442
+ {hasChoice && <IntervalToggle interval={interval} onChange={setInterval} />}
427
443
  <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
428
- {allOffers.map((offer) => {
444
+ {entries.map((entry) => {
445
+ const { offer, tier } = entry;
429
446
  const direction = directionFor(offer);
430
447
  const isCurrent = direction === "current";
431
- const isDowngrade = direction === "downgrade";
448
+ const isDowngrade = direction === "downgrade" || direction === "switch";
432
449
  const ctaLabel = ctaLabelFor(offer, direction);
433
450
 
434
451
  // Border: current = solid ring; featured upgrade = blue; else gray.
@@ -459,25 +476,17 @@ export function SubscriptionManager({
459
476
  data-testid={`current-plan-badge-${offer.slug}`}
460
477
  className="absolute -top-2.5 right-4 inline-block bg-primary text-primary-foreground text-xs font-bold uppercase tracking-wide px-2.5 py-0.5 rounded-full"
461
478
  >
462
- Current Plan
479
+ Current plan
463
480
  </span>
464
481
  )}
465
- <h4 className="text-lg font-bold text-gray-900 mb-2">{offer.name}</h4>
482
+ <h4 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h4>
466
483
  <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>
470
- </span>
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>
479
- </span>
480
- )}
484
+ <OfferPrice
485
+ offer={offer}
486
+ amountClassName="text-2xl font-extrabold text-gray-900"
487
+ intervalClassName="text-sm text-gray-500"
488
+ />
489
+ <AnnualNote entry={entry} className="mt-1" />
481
490
  </div>
482
491
  {/* Trial copy is for prospective trialers only — once the
483
492
  user is on this offer or moving downward, drop the
@@ -13,12 +13,19 @@ import React, { useEffect, useRef, useCallback } from "react";
13
13
  import { useProduct } from "../hooks/useProduct";
14
14
  import type { ProductOffer } from "../types";
15
15
  import { FeatureIcon } from "./FeatureIcon";
16
+ import { formatFeatureLabel } from "../utils/format";
17
+ import { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
18
+ import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers } from "./IntervalToggle";
16
19
 
17
20
  export interface UpgradeModalProps {
18
21
  productId: string;
19
22
  open: boolean;
20
23
  onClose: () => void;
21
24
  onSelectOffer?: (offer: ProductOffer) => void;
25
+
26
+ /** The viewer's plan, so their card reads "Current plan" and is disabled.
27
+ * Omit on signed-out pages. crochet_patterns-34n7. */
28
+ currentPlan?: CurrentPlanState | null;
22
29
  }
23
30
 
24
31
  export function UpgradeModal({
@@ -26,8 +33,13 @@ export function UpgradeModal({
26
33
  open,
27
34
  onClose,
28
35
  onSelectOffer,
36
+ currentPlan,
29
37
  }: UpgradeModalProps) {
30
38
  const { product, loading, error } = useProduct(productId);
39
+ const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
40
+ product?.offers ?? [],
41
+ resolveCurrentOfferId(product?.offers ?? [], currentPlan),
42
+ );
31
43
  const modalRef = useRef<HTMLDivElement>(null);
32
44
  const previousFocusRef = useRef<HTMLElement | null>(null);
33
45
 
@@ -120,10 +132,18 @@ export function UpgradeModal({
120
132
  </div>
121
133
  )}
122
134
 
135
+ {product && hasChoice && (
136
+ <div className="mb-6">
137
+ <IntervalToggle interval={interval} onChange={setInterval} />
138
+ </div>
139
+ )}
140
+
123
141
  {product && (
124
142
  <div data-testid="upgrade-offers" className="flex flex-wrap justify-center gap-5">
125
- {product.offers.map((offer) => {
143
+ {entries.map((entry) => {
144
+ const { offer, tier } = entry;
126
145
  const isFeatured = offer.isFeatured;
146
+ const isCurrent = offer.id === resolveCurrentOfferId(product.offers, currentPlan);
127
147
  return (
128
148
  <div
129
149
  key={offer.id}
@@ -136,21 +156,15 @@ export function UpgradeModal({
136
156
  }
137
157
  `}
138
158
  >
139
- <h3 className="text-lg font-bold text-gray-900 mb-2">{offer.name}</h3>
159
+ <h3 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h3>
140
160
 
141
161
  <div className="mb-2">
142
- {offer.isFree ? (
143
- <span aria-label="Free">
144
- <span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">Free</span>
145
- </span>
146
- ) : (
147
- <span aria-label={`${Math.floor(parseFloat(offer.unitPrice) || 0)} dollars ${offer.intervalDisplay}`}>
148
- <span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">
149
- ${Math.floor(parseFloat(offer.unitPrice) || 0)}
150
- </span>
151
- <span aria-hidden="true" className="text-sm text-gray-500"> {offer.intervalDisplay}</span>
152
- </span>
153
- )}
162
+ <OfferPrice
163
+ offer={offer}
164
+ amountClassName="text-2xl font-extrabold text-gray-900"
165
+ intervalClassName="text-sm text-gray-500"
166
+ />
167
+ <AnnualNote entry={entry} className="mt-1" />
154
168
  </div>
155
169
 
156
170
  {offer.trialDays > 0 && (
@@ -164,24 +178,28 @@ export function UpgradeModal({
164
178
  {offer.features.map((f) => (
165
179
  <li key={f.key} className="flex items-center gap-1.5 py-1 text-sm text-gray-600">
166
180
  <FeatureIcon value={f.value} size="w-3.5 h-3.5" />
167
- {f.name}
181
+ <span>{formatFeatureLabel(f)}</span>
168
182
  </li>
169
183
  ))}
170
184
  </ul>
171
185
  )}
172
186
 
173
187
  <button
174
- onClick={() => onSelectOffer?.(offer)}
188
+ onClick={() => !isCurrent && onSelectOffer?.(offer)}
175
189
  data-testid={`upgrade-cta-${offer.slug}`}
190
+ disabled={isCurrent}
191
+ aria-disabled={isCurrent || undefined}
176
192
  className={`
177
- w-full py-2.5 px-4 text-sm font-bold rounded-lg cursor-pointer transition-colors
178
- ${isFeatured
179
- ? "bg-blue-600 text-white hover:bg-blue-700"
180
- : "bg-gray-100 text-gray-800 hover:bg-gray-200"
193
+ w-full py-2.5 px-4 text-sm font-bold rounded-lg transition-colors
194
+ ${isCurrent
195
+ ? "bg-gray-100 text-gray-500 cursor-not-allowed"
196
+ : isFeatured
197
+ ? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
198
+ : "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
181
199
  }
182
200
  `}
183
201
  >
184
- {offer.ctaText}
202
+ {isCurrent ? "Current plan" : offer.ctaText}
185
203
  </button>
186
204
  </div>
187
205
  );
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The live crochets.site catalogue (GET /billing/products/crochet-patterns/,
3
+ * 2026-10-16), camelised the way the proxy hands it to the components.
4
+ * `tier` is not served yet, so the base fixture omits it on purpose: the
5
+ * picker has to pair Lite with Lite Annual without it.
6
+ */
7
+ import type { BillingProduct, ProductOffer, SubscriptionInfo } from "../../types";
8
+
9
+ const base = {
10
+ currency: "USD",
11
+ pricingModel: "flat" as const,
12
+ intervalCount: 1,
13
+ isRecurring: true,
14
+ isActive: true,
15
+ isFeatured: false,
16
+ };
17
+
18
+ const liteFeatures = [
19
+ { key: "ai_designs", name: "AI pattern designs", value: true },
20
+ { key: "ai_designs_per_month", name: "AI designs per month", value: 3 },
21
+ { key: "ai_refine", name: "AI chat to refine any pattern", value: true },
22
+ { key: "pdf_export", name: "Print-ready PDF export", value: true },
23
+ { key: "save_patterns", name: "Save patterns to your library", value: true },
24
+ { key: "track_progress", name: "Track your progress", value: true },
25
+ { key: "patterns_per_month", name: "Saved patterns per month", value: -1 },
26
+ ];
27
+
28
+ const proFeatures = [
29
+ { key: "ai_designs", name: "AI pattern designs, made with our best model and checked by a design review", value: true },
30
+ { key: "patterns_per_month", name: "AI designs per month", value: -1 },
31
+ { key: "ai_refine", name: "AI chat to refine any pattern", value: true },
32
+ { key: "pdf_export", name: "Print-ready PDF export", value: true },
33
+ { key: "custom_sizing", name: "Custom sizing", value: true },
34
+ { key: "trial_note", name: "7-day free trial with AI designs included", value: true },
35
+ ];
36
+
37
+ export const crochetFree: ProductOffer = {
38
+ ...base,
39
+ id: "free-id",
40
+ name: "Free",
41
+ slug: "crochet-free",
42
+ unitPrice: "0.00",
43
+ intervalUnit: "month",
44
+ intervalDisplay: "per month",
45
+ isFree: true,
46
+ features: [
47
+ { key: "ai_designs", name: "AI pattern designs", value: false },
48
+ { key: "browse_patterns", name: "Browse and buy patterns", value: true },
49
+ { key: "save_patterns", name: "Save patterns to your library", value: true },
50
+ { key: "track_progress", name: "Track your progress", value: true },
51
+ { key: "pdf_export", name: "Print-ready PDF export", limit: null, value: true },
52
+ ],
53
+ trialDays: 0,
54
+ sortOrder: 0,
55
+ ctaText: "Get Started",
56
+ };
57
+
58
+ export const crochetLite: ProductOffer = {
59
+ ...base,
60
+ id: "lite-id",
61
+ name: "Lite",
62
+ slug: "crochet-lite",
63
+ unitPrice: "2.99",
64
+ intervalUnit: "month",
65
+ intervalDisplay: "per month",
66
+ isFree: false,
67
+ features: liteFeatures,
68
+ trialDays: 0,
69
+ sortOrder: 1,
70
+ ctaText: "Get Lite",
71
+ };
72
+
73
+ export const crochetLiteAnnual: ProductOffer = {
74
+ ...base,
75
+ id: "lite-annual-id",
76
+ name: "Lite Annual",
77
+ slug: "crochet-lite-annual",
78
+ unitPrice: "29.00",
79
+ intervalUnit: "year",
80
+ intervalDisplay: "per year",
81
+ isFree: false,
82
+ features: liteFeatures,
83
+ trialDays: 0,
84
+ sortOrder: 1,
85
+ ctaText: "Get Lite",
86
+ };
87
+
88
+ export const crochetPro: ProductOffer = {
89
+ ...base,
90
+ id: "pro-id",
91
+ name: "Pro",
92
+ slug: "crochet-pro",
93
+ unitPrice: "9.00",
94
+ intervalUnit: "month",
95
+ intervalDisplay: "per month",
96
+ isFree: false,
97
+ features: proFeatures,
98
+ trialDays: 7,
99
+ sortOrder: 2,
100
+ ctaText: "Start Free Trial",
101
+ };
102
+
103
+ export const crochetProAnnual: ProductOffer = {
104
+ ...base,
105
+ id: "pro-annual-id",
106
+ name: "Pro Annual",
107
+ slug: "crochet-pro-annual",
108
+ unitPrice: "89.00",
109
+ intervalUnit: "year",
110
+ intervalDisplay: "per year",
111
+ isFree: false,
112
+ features: proFeatures,
113
+ trialDays: 7,
114
+ sortOrder: 2,
115
+ ctaText: "Start Free Trial",
116
+ };
117
+
118
+ /** What production serves today: no Pro Annual, no `tier`. */
119
+ export const crochetProduct: BillingProduct = {
120
+ id: "034a56c9",
121
+ slug: "crochet-patterns",
122
+ name: "Crochet Patterns",
123
+ description: "AI-powered crochet pattern generator with step-by-step instructions.",
124
+ offers: [crochetFree, crochetLite, crochetLiteAnnual, crochetPro],
125
+ };
126
+
127
+ /** After the in-flight deploy: `tier` on every paid offer, plus Pro Annual. */
128
+ export const crochetProductWithTiers: BillingProduct = {
129
+ ...crochetProduct,
130
+ offers: [
131
+ crochetFree,
132
+ { ...crochetLite, tier: "lite" },
133
+ { ...crochetLiteAnnual, tier: "lite" },
134
+ { ...crochetPro, tier: "pro" },
135
+ { ...crochetProAnnual, tier: "pro" },
136
+ ],
137
+ };
138
+
139
+ export function subscriptionTo(offer: ProductOffer, status: SubscriptionInfo["status"] = "active"): SubscriptionInfo {
140
+ return {
141
+ id: `sub-${offer.slug}`,
142
+ offer,
143
+ status,
144
+ currentPeriodStart: "2026-10-01T00:00:00Z",
145
+ currentPeriodEnd: "2026-11-01T00:00:00Z",
146
+ cancelAtPeriodEnd: false,
147
+ trialEnd: null,
148
+ viewerIsOwner: true,
149
+ };
150
+ }
151
+
152
+ /** A fetch that serves the product and the given subscription (null = 404). */
153
+ export function crochetFetcher(
154
+ product: BillingProduct,
155
+ subscription: SubscriptionInfo | null,
156
+ ): typeof fetch {
157
+ return ((url: string) => {
158
+ if (url.includes("/billing/subscription/current/")) {
159
+ if (subscription === null) {
160
+ return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({ detail: "none" }) });
161
+ }
162
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(subscription) });
163
+ }
164
+ if (url.includes("/billing/usage/")) {
165
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) });
166
+ }
167
+ if (url.includes("/billing/products/")) {
168
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(product) });
169
+ }
170
+ return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
171
+ }) as unknown as typeof fetch;
172
+ }
package/src/index.ts CHANGED
@@ -57,5 +57,8 @@ export type {
57
57
  export { BillingApiClient } from "./utils/api";
58
58
 
59
59
  // Pure helpers
60
- export { isPaidSubscription, getFeatureLimit, resolvePlanEntitlement, resolveUsageWindow } from "./utils/plan";
61
- export type { PlanEntitlement, ResolvePlanEntitlementOptions, UsageWindow } from "./utils/plan";
60
+ export { isPaidSubscription, getFeatureLimit, resolvePlanEntitlement, resolveUsageWindow, resolveCurrentOfferId } from "./utils/plan";
61
+ export type { PlanEntitlement, ResolvePlanEntitlementOptions, UsageWindow, CurrentPlanState } from "./utils/plan";
62
+ export { formatPrice, formatFeatureLabel } from "./utils/format";
63
+ export { groupOffersByTier, offersForInterval, hasIntervalChoice, annualSavings, tierOffer } from "./utils/tiers";
64
+ export type { BillingInterval, OfferTier } from "./utils/tiers";
@@ -23,6 +23,10 @@ export interface ProductOffer {
23
23
  trialDays: number;
24
24
  sortOrder: number;
25
25
  ctaText: string;
26
+ /** Plan tier shared by an offer's monthly and annual prices ("lite",
27
+ * "pro"). Optional: older payloads omit it and the picker pairs by
28
+ * features / name instead. */
29
+ tier?: string | null;
26
30
  }
27
31
 
28
32
  export interface OfferFeature {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Price and feature-label formatting shared by every plan card
3
+ * (crochet_patterns-34n7).
4
+ */
5
+ import { describe, it, expect } from "vitest";
6
+ import { formatPrice, formatFeatureLabel } from "./format";
7
+
8
+ describe("formatPrice", () => {
9
+ it("keeps the cents: 2.99 is $2.99, not $2", () => {
10
+ expect(formatPrice("2.99")).toBe("$2.99");
11
+ });
12
+
13
+ it("drops .00 on whole amounts", () => {
14
+ expect(formatPrice("9.00")).toBe("$9");
15
+ expect(formatPrice("29.00")).toBe("$29");
16
+ expect(formatPrice("199.00")).toBe("$199");
17
+ });
18
+
19
+ it("pads a single cent digit", () => {
20
+ expect(formatPrice("2.5")).toBe("$2.50");
21
+ });
22
+
23
+ it("accepts numbers and other currencies", () => {
24
+ expect(formatPrice(2.42)).toBe("$2.42");
25
+ expect(formatPrice("10.00", "EUR")).toBe("€10");
26
+ });
27
+
28
+ it("treats junk as zero rather than NaN", () => {
29
+ expect(formatPrice("")).toBe("$0");
30
+ });
31
+ });
32
+
33
+ describe("formatFeatureLabel", () => {
34
+ it("keeps acronyms capitalised in a capped allowance", () => {
35
+ expect(formatFeatureLabel({ key: "ai_designs_per_month", name: "AI designs per month", value: 3 }))
36
+ .toBe("Up to 3 AI designs per month");
37
+ });
38
+
39
+ it("does not say per month for an unlimited allowance", () => {
40
+ expect(formatFeatureLabel({ key: "patterns_per_month", name: "Saved patterns per month", value: -1 }))
41
+ .toBe("Unlimited saved patterns");
42
+ expect(formatFeatureLabel({ key: "patterns_per_month", name: "AI designs per month", value: -1 }))
43
+ .toBe("Unlimited AI designs");
44
+ });
45
+
46
+ it("prefers limit over value", () => {
47
+ expect(formatFeatureLabel({ key: "seats", name: "Team seats", value: 1, limit: 5 }))
48
+ .toBe("Up to 5 team seats");
49
+ });
50
+
51
+ it("leaves capabilities and string values as their name", () => {
52
+ expect(formatFeatureLabel({ key: "pdf_export", name: "Print-ready PDF export", value: true }))
53
+ .toBe("Print-ready PDF export");
54
+ expect(formatFeatureLabel({ key: "projects", name: "Projects", value: "Unlimited" }))
55
+ .toBe("Projects");
56
+ });
57
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Display formatting shared by every plan card, so a price or a feature line
3
+ * reads the same on /pricing, the billing settings page and the upgrade modal
4
+ * (crochet_patterns-34n7).
5
+ */
6
+ import type { OfferFeature } from "../types";
7
+
8
+ /**
9
+ * "$2.99", "$9", "€10". Cents are kept when there are any and dropped when
10
+ * the amount is whole — truncating with Math.floor turned $2.99 into $2.
11
+ */
12
+ export function formatPrice(amount: string | number, currency = "USD"): string {
13
+ const n = typeof amount === "number" ? amount : parseFloat(amount);
14
+ const value = Number.isFinite(n) ? n : 0;
15
+ const whole = Math.abs(value - Math.round(value)) < 0.005;
16
+ try {
17
+ return new Intl.NumberFormat("en-US", {
18
+ style: "currency",
19
+ currency: currency || "USD",
20
+ minimumFractionDigits: whole ? 0 : 2,
21
+ maximumFractionDigits: whole ? 0 : 2,
22
+ }).format(value);
23
+ } catch {
24
+ return `$${whole ? Math.round(value) : value.toFixed(2)}`;
25
+ }
26
+ }
27
+
28
+ /** Lower-case the first letter for mid-sentence use, unless the first word is
29
+ * an acronym ("AI designs" stays "AI designs", "Team seats" → "team seats"). */
30
+ function midSentence(name: string): string {
31
+ const firstWord = name.split(/\s+/)[0] ?? "";
32
+ if ((firstWord.match(/[A-Z]/g) ?? []).length >= 2) return name;
33
+ return name.charAt(0).toLowerCase() + name.slice(1);
34
+ }
35
+
36
+ /** "Saved patterns per month" → "Saved patterns": an unlimited allowance has
37
+ * no period. */
38
+ function withoutPeriod(name: string): string {
39
+ return name.replace(/\s+(per|a|each|\/)\s*(day|week|month|year)$/i, "");
40
+ }
41
+
42
+ /**
43
+ * The line a plan card shows for one feature. Numeric allowances read as
44
+ * "Up to 3 AI designs per month" / "Unlimited saved patterns"; capabilities
45
+ * and string values are shown by name. Callers decide what to do with a
46
+ * feature whose value is `false`.
47
+ */
48
+ export function formatFeatureLabel(feature: OfferFeature): string {
49
+ const { name, value, limit } = feature;
50
+ const n = typeof limit === "number" ? limit : typeof value === "number" ? value : null;
51
+ if (n === -1) return `Unlimited ${midSentence(withoutPeriod(name))}`;
52
+ if (n !== null && n > 0) return `Up to ${n} ${midSentence(name)}`;
53
+ return name;
54
+ }
package/src/utils/plan.ts CHANGED
@@ -201,3 +201,41 @@ export function resolveUsageWindow(
201
201
  );
202
202
  return { start: monthStart, end: monthEnd };
203
203
  }
204
+
205
+ /**
206
+ * What a plan picker knows about the viewer. Omit it (signed-out pages) and
207
+ * no card is marked current.
208
+ */
209
+ export interface CurrentPlanState {
210
+ signedIn: boolean;
211
+ subscription: SubscriptionInfo | null | undefined;
212
+ /** The subscription is still being fetched. */
213
+ loading?: boolean;
214
+ /** The subscription fetch failed. */
215
+ error?: unknown;
216
+ }
217
+
218
+ /**
219
+ * The offer to mark "Current plan" on a picker (crochet_patterns-34n7).
220
+ *
221
+ * A signed-in user with no subscription row is on the free plan — that is
222
+ * what every gate already assumes — so the free card is theirs. A paid plan
223
+ * counts while it is active, trialing or past due; a lapsed one falls back to
224
+ * free so the user can buy it again. Nothing is marked while the plan is
225
+ * unknown (loading / failed): a paying user must never see Free flash as
226
+ * current with their real plan offered as an upgrade.
227
+ */
228
+ export function resolveCurrentOfferId(
229
+ offers: ProductOffer[],
230
+ state: CurrentPlanState | null | undefined,
231
+ ): string | null {
232
+ if (!state || !state.signedIn || state.loading || state.error) return null;
233
+ const offer = state.subscription?.offer ?? null;
234
+ if (offer) {
235
+ if (offer.isFree) return offer.id;
236
+ if (isPaidSubscription(state.subscription) || state.subscription?.status === "past_due") {
237
+ return offer.id;
238
+ }
239
+ }
240
+ return offers.find((o) => o.isFree)?.id ?? null;
241
+ }