@startsimpli/billing 0.4.22 → 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.
@@ -12,7 +12,10 @@ import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
13
  import type { ProductOffer, OfferFeature } from "../types";
14
14
  import { cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
15
+ import { formatFeatureLabel } from "../utils/format";
16
+ import { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
15
17
  import { FeatureIcon } from "./FeatureIcon";
18
+ import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers, type IntervalEntry } from "./IntervalToggle";
16
19
 
17
20
  export interface PricingDetailPageProps {
18
21
  /** Product slug (e.g., "raise-simpli") */
@@ -23,14 +26,23 @@ export interface PricingDetailPageProps {
23
26
 
24
27
  /** Whether to show the feature comparison table (defaults to true) */
25
28
  showComparison?: boolean;
29
+
30
+ /** The viewer's plan, so their card reads "Current plan" and is disabled.
31
+ * Omit on signed-out pages. crochet_patterns-34n7. */
32
+ currentPlan?: CurrentPlanState | null;
26
33
  }
27
34
 
28
35
  export function PricingDetailPage({
29
36
  productId,
30
37
  onSelectOffer,
31
38
  showComparison = true,
39
+ currentPlan,
32
40
  }: PricingDetailPageProps) {
33
41
  const { product, loading, error } = useProduct(productId);
42
+ const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
43
+ product?.offers ?? [],
44
+ resolveCurrentOfferId(product?.offers ?? [], currentPlan),
45
+ );
34
46
 
35
47
  if (loading) {
36
48
  return (
@@ -56,10 +68,16 @@ export function PricingDetailPage({
56
68
  );
57
69
  }
58
70
 
71
+ // One offer per tier for the chosen interval: cards, "Everything in X" and
72
+ // the comparison columns all read this, never the raw list (which would
73
+ // add a "Lite Annual" column beside "Lite").
74
+ const visibleOffers = entries.map((e) => e.offer);
75
+ const currentOfferId = resolveCurrentOfferId(product.offers, currentPlan);
76
+
59
77
  // Extract all unique feature keys across all offers (preserving order)
60
78
  const allFeatureKeys: string[] = [];
61
79
  const featureNameMap: Record<string, string> = {};
62
- for (const offer of product.offers) {
80
+ for (const offer of visibleOffers) {
63
81
  for (const feature of offer.features) {
64
82
  if (!allFeatureKeys.includes(feature.key)) {
65
83
  allFeatureKeys.push(feature.key);
@@ -79,20 +97,30 @@ export function PricingDetailPage({
79
97
  </p>
80
98
  </div>
81
99
 
100
+ {hasChoice && (
101
+ <div className="mb-10">
102
+ <IntervalToggle interval={interval} onChange={setInterval} />
103
+ </div>
104
+ )}
105
+
82
106
  <div className="flex flex-wrap justify-center gap-6 items-stretch mb-16">
83
- {product.offers.map((offer) => (
84
- <DetailCard
85
- key={offer.id}
86
- offer={offer}
87
- onSelect={onSelectOffer}
88
- buildsOn={tierBelow(product.offers, offer)?.name ?? null}
89
- />
90
- ))}
107
+ {entries.map((entry) => {
108
+ const below = tierBelow(visibleOffers, entry.offer);
109
+ return (
110
+ <DetailCard
111
+ key={entry.offer.id}
112
+ entry={entry}
113
+ onSelect={onSelectOffer}
114
+ buildsOn={below ? entries.find((e) => e.offer.id === below.id)?.tier.name ?? below.name : null}
115
+ isCurrent={entry.offer.id === currentOfferId}
116
+ />
117
+ );
118
+ })}
91
119
  </div>
92
120
 
93
121
  {showComparison && (
94
122
  <ComparisonTable
95
- offers={product.offers}
123
+ entries={entries}
96
124
  featureKeys={allFeatureKeys}
97
125
  featureNameMap={featureNameMap}
98
126
  />
@@ -102,13 +130,16 @@ export function PricingDetailPage({
102
130
  }
103
131
 
104
132
  interface DetailCardProps {
105
- offer: ProductOffer;
133
+ entry: IntervalEntry;
106
134
  onSelect?: (offer: ProductOffer) => void;
107
135
  /** Name of the tier just below this one, when there is one. */
108
136
  buildsOn?: string | null;
137
+ /** The viewer is on this offer: "Current plan", disabled. */
138
+ isCurrent?: boolean;
109
139
  }
110
140
 
111
- function DetailCard({ offer, onSelect, buildsOn }: DetailCardProps) {
141
+ function DetailCard({ entry, onSelect, buildsOn, isCurrent = false }: DetailCardProps) {
142
+ const { offer, tier } = entry;
112
143
  const isFeatured = offer.isFeatured;
113
144
 
114
145
  return (
@@ -128,21 +159,15 @@ function DetailCard({ offer, onSelect, buildsOn }: DetailCardProps) {
128
159
  </span>
129
160
  )}
130
161
 
131
- <h2 className="text-xl font-bold text-gray-900 mb-4">{offer.name}</h2>
162
+ <h2 className="text-xl font-bold text-gray-900 mb-4">{tier.name}</h2>
132
163
 
133
164
  <div className="mb-2">
134
- {offer.isFree ? (
135
- <span aria-label="Free">
136
- <span aria-hidden="true" className="text-4xl font-extrabold text-gray-900">Free</span>
137
- </span>
138
- ) : (
139
- <span aria-label={`${Math.floor(parseFloat(offer.unitPrice) || 0)} dollars ${offer.intervalDisplay}`}>
140
- <span aria-hidden="true" className="text-4xl font-extrabold text-gray-900">
141
- ${Math.floor(parseFloat(offer.unitPrice) || 0)}
142
- </span>
143
- <span aria-hidden="true" className="text-base text-gray-500 ml-1"> {offer.intervalDisplay}</span>
144
- </span>
145
- )}
165
+ <OfferPrice
166
+ offer={offer}
167
+ amountClassName="text-4xl font-extrabold text-gray-900"
168
+ intervalClassName="text-base text-gray-500"
169
+ />
170
+ <AnnualNote entry={entry} className="mt-1" />
146
171
  </div>
147
172
 
148
173
  {offer.trialDays > 0 && (
@@ -164,40 +189,43 @@ function DetailCard({ offer, onSelect, buildsOn }: DetailCardProps) {
164
189
  className="flex items-center gap-2.5 py-2.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
165
190
  >
166
191
  <FeatureIcon value={feature.value} />
167
- <span>{feature.name}</span>
168
- {typeof feature.value !== "boolean" && feature.value !== null && feature.value !== undefined && (
169
- <span className="ml-auto text-gray-900 font-medium">
170
- {feature.value === -1 ? "Unlimited" : String(feature.value)}
171
- </span>
192
+ <span>{formatFeatureLabel(feature)}</span>
193
+ {typeof feature.value === "string" && (
194
+ <span className="ml-auto text-gray-900 font-medium">{feature.value}</span>
172
195
  )}
173
196
  </li>
174
197
  ))}
175
198
  </ul>
176
199
 
177
200
  <button
178
- onClick={() => onSelect?.(offer)}
201
+ onClick={() => !isCurrent && onSelect?.(offer)}
179
202
  data-testid={`detail-cta-${offer.slug}`}
203
+ disabled={isCurrent}
204
+ aria-disabled={isCurrent || undefined}
180
205
  className={`
181
- w-full py-3.5 px-6 text-base font-bold rounded-xl cursor-pointer transition-colors
182
- ${isFeatured
183
- ? "bg-blue-600 text-white hover:bg-blue-700"
184
- : "bg-gray-100 text-gray-800 hover:bg-gray-200"
206
+ w-full py-3.5 px-6 text-base font-bold rounded-xl transition-colors
207
+ ${isCurrent
208
+ ? "bg-gray-100 text-gray-500 cursor-not-allowed"
209
+ : isFeatured
210
+ ? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
211
+ : "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
185
212
  }
186
213
  `}
187
214
  >
188
- {offer.ctaText}
215
+ {isCurrent ? "Current plan" : offer.ctaText}
189
216
  </button>
190
217
  </div>
191
218
  );
192
219
  }
193
220
 
194
221
  interface ComparisonTableProps {
195
- offers: ProductOffer[];
222
+ entries: IntervalEntry[];
196
223
  featureKeys: string[];
197
224
  featureNameMap: Record<string, string>;
198
225
  }
199
226
 
200
- function ComparisonTable({ offers, featureKeys, featureNameMap }: ComparisonTableProps) {
227
+ function ComparisonTable({ entries, featureKeys, featureNameMap }: ComparisonTableProps) {
228
+ const offers = entries.map((e) => e.offer);
201
229
  // offer slug -> feature key -> feature, with cheaper tiers' capabilities
202
230
  // carried upward so a paid plan never shows a dash where free shows a tick.
203
231
  const featureLookup = cumulativeFeatureLookup(offers);
@@ -210,14 +238,14 @@ function ComparisonTable({ offers, featureKeys, featureNameMap }: ComparisonTabl
210
238
  <th scope="col" className="text-left py-4 px-4 text-sm font-semibold text-gray-500 uppercase tracking-wider">
211
239
  Feature
212
240
  </th>
213
- {offers.map((offer) => (
241
+ {entries.map(({ offer, tier }) => (
214
242
  <th
215
243
  key={offer.slug}
216
244
  scope="col"
217
- aria-label={offer.name}
245
+ aria-label={tier.name}
218
246
  className="text-center py-4 px-4 text-sm font-semibold text-gray-900"
219
247
  >
220
- <span aria-hidden="true">{offer.name}</span>
248
+ <span aria-hidden="true">{tier.name}</span>
221
249
  </th>
222
250
  ))}
223
251
  </tr>
@@ -218,9 +218,9 @@ describe("PricingPage", () => {
218
218
 
219
219
  await waitFor(() => {
220
220
  // Each offer has features; "Team seats" appears in all 3 cards
221
- expect(screen.getAllByText("Team seats").length).toBe(3);
221
+ expect(screen.getAllByText(/team seats$/i).length).toBe(3);
222
222
  });
223
- expect(screen.getAllByText("Projects").length).toBe(3);
223
+ expect(screen.getAllByText(/projects$/i).length).toBe(3);
224
224
  });
225
225
 
226
226
  it("shows trial badge when offer has trial days", async () => {
@@ -12,6 +12,9 @@ import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
13
  import type { ProductOffer } from "../types";
14
14
  import { FeatureIcon } from "./FeatureIcon";
15
+ import { formatFeatureLabel } from "../utils/format";
16
+ import { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
17
+ import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers, type IntervalEntry } from "./IntervalToggle";
15
18
 
16
19
  export interface PricingPageProps {
17
20
  /** Product slug (e.g., "raise-simpli") */
@@ -19,10 +22,18 @@ export interface PricingPageProps {
19
22
 
20
23
  /** Called when user clicks an offer's CTA button */
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
- export function PricingPage({ productId, onSelectOffer }: PricingPageProps) {
31
+ export function PricingPage({ productId, onSelectOffer, currentPlan }: PricingPageProps) {
25
32
  const { product, loading, error } = useProduct(productId);
33
+ const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
34
+ product?.offers ?? [],
35
+ resolveCurrentOfferId(product?.offers ?? [], currentPlan),
36
+ );
26
37
 
27
38
  if (loading) {
28
39
  return (
@@ -48,6 +59,8 @@ export function PricingPage({ productId, onSelectOffer }: PricingPageProps) {
48
59
  );
49
60
  }
50
61
 
62
+ const currentOfferId = resolveCurrentOfferId(product.offers, currentPlan);
63
+
51
64
  return (
52
65
  <div data-testid="pricing-page" className="max-w-5xl mx-auto px-6 py-12">
53
66
  <div className="text-center mb-12">
@@ -59,12 +72,19 @@ export function PricingPage({ productId, onSelectOffer }: PricingPageProps) {
59
72
  </p>
60
73
  </div>
61
74
 
75
+ {hasChoice && (
76
+ <div className="mb-10">
77
+ <IntervalToggle interval={interval} onChange={setInterval} />
78
+ </div>
79
+ )}
80
+
62
81
  <div data-testid="pricing-cards" className="flex flex-wrap justify-center gap-6 items-stretch">
63
- {product.offers.map((offer) => (
82
+ {entries.map((entry) => (
64
83
  <PricingCard
65
- key={offer.id}
66
- offer={offer}
84
+ key={entry.offer.id}
85
+ entry={entry}
67
86
  onSelect={onSelectOffer}
87
+ isCurrent={entry.offer.id === currentOfferId}
68
88
  />
69
89
  ))}
70
90
  </div>
@@ -73,11 +93,13 @@ export function PricingPage({ productId, onSelectOffer }: PricingPageProps) {
73
93
  }
74
94
 
75
95
  interface PricingCardProps {
76
- offer: ProductOffer;
96
+ entry: IntervalEntry;
77
97
  onSelect?: (offer: ProductOffer) => void;
98
+ isCurrent?: boolean;
78
99
  }
79
100
 
80
- function PricingCard({ offer, onSelect }: PricingCardProps) {
101
+ function PricingCard({ entry, onSelect, isCurrent = false }: PricingCardProps) {
102
+ const { offer, tier } = entry;
81
103
  const isFeatured = offer.isFeatured;
82
104
 
83
105
  return (
@@ -98,19 +120,15 @@ function PricingCard({ offer, onSelect }: PricingCardProps) {
98
120
  </span>
99
121
  )}
100
122
 
101
- <h2 className="text-xl font-bold text-gray-900 mb-4">{offer.name}</h2>
123
+ <h2 className="text-xl font-bold text-gray-900 mb-4">{tier.name}</h2>
102
124
 
103
125
  <div className="mb-2">
104
- {offer.isFree ? (
105
- <span className="text-4xl font-extrabold text-gray-900">Free</span>
106
- ) : (
107
- <span>
108
- <span className="text-4xl font-extrabold text-gray-900">
109
- ${Math.floor(parseFloat(offer.unitPrice) || 0)}
110
- </span>
111
- <span className="text-base text-gray-500 ml-1"> {offer.intervalDisplay}</span>
112
- </span>
113
- )}
126
+ <OfferPrice
127
+ offer={offer}
128
+ amountClassName="text-4xl font-extrabold text-gray-900"
129
+ intervalClassName="text-base text-gray-500"
130
+ />
131
+ <AnnualNote entry={entry} className="mt-1" />
114
132
  </div>
115
133
 
116
134
  {offer.trialDays > 0 && (
@@ -129,23 +147,27 @@ function PricingCard({ offer, onSelect }: PricingCardProps) {
129
147
  className="flex items-center gap-2.5 py-2.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
130
148
  >
131
149
  <FeatureIcon value={feature.value} />
132
- <span>{feature.name}</span>
150
+ <span>{formatFeatureLabel(feature)}</span>
133
151
  </li>
134
152
  ))}
135
153
  </ul>
136
154
 
137
155
  <button
138
- onClick={() => onSelect?.(offer)}
156
+ onClick={() => !isCurrent && onSelect?.(offer)}
139
157
  data-testid={`cta-${offer.slug}`}
158
+ disabled={isCurrent}
159
+ aria-disabled={isCurrent || undefined}
140
160
  className={`
141
- w-full py-3.5 px-6 text-base font-bold rounded-xl cursor-pointer transition-colors
142
- ${isFeatured
143
- ? "bg-blue-600 text-white hover:bg-blue-700"
144
- : "bg-gray-100 text-gray-800 hover:bg-gray-200"
161
+ w-full py-3.5 px-6 text-base font-bold rounded-xl transition-colors
162
+ ${isCurrent
163
+ ? "bg-gray-100 text-gray-500 cursor-not-allowed"
164
+ : isFeatured
165
+ ? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
166
+ : "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
145
167
  }
146
168
  `}
147
169
  >
148
- {offer.ctaText}
170
+ {isCurrent ? "Current plan" : offer.ctaText}
149
171
  </button>
150
172
  </div>
151
173
  );
@@ -217,10 +217,11 @@ describe("PricingSection", () => {
217
217
  expect(screen.getByText("Starter")).toBeDefined();
218
218
  });
219
219
 
220
- // Starter has 5 features, but should only show 2
220
+ // Starter has 5 features, but should only show 2. Numeric allowances read
221
+ // "Up to 1 team seats" since crochet_patterns-34n7, so match the noun.
221
222
  // We should see "Team seats" and "Projects" (first 2), but not "Storage"
222
- expect(screen.getAllByText("Team seats").length).toBe(3); // All 3 cards
223
- expect(screen.getAllByText("Projects").length).toBe(3); // All 3 cards
223
+ expect(screen.getAllByText(/team seats$/i).length).toBe(3); // All 3 cards
224
+ expect(screen.getAllByText(/projects$/i).length).toBe(3); // All 3 cards
224
225
  // Storage should appear fewer times or not at all since we're limiting to 2 features per card
225
226
  const storageElements = screen.queryAllByText("Storage");
226
227
  expect(storageElements.length).toBeLessThan(3); // Should not appear in all cards
@@ -240,8 +241,8 @@ describe("PricingSection", () => {
240
241
  });
241
242
 
242
243
  // With default maxFeatures=4, should show first 4 features
243
- expect(screen.getAllByText("Team seats").length).toBe(3);
244
- expect(screen.getAllByText("Projects").length).toBe(3);
244
+ expect(screen.getAllByText(/team seats$/i).length).toBe(3);
245
+ expect(screen.getAllByText(/projects$/i).length).toBe(3);
245
246
  expect(screen.getAllByText("Storage").length).toBe(3);
246
247
  expect(screen.getAllByText("Support").length).toBe(3);
247
248
  });
@@ -12,6 +12,10 @@ import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
13
  import type { ProductOffer } from "../types";
14
14
  import { tierBelow } from "../utils/tiers";
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, type IntervalEntry } from "./IntervalToggle";
15
19
 
16
20
  export interface PricingSectionProps {
17
21
  /** Product slug (e.g., "raise-simpli") */
@@ -22,14 +26,23 @@ export interface PricingSectionProps {
22
26
 
23
27
  /** Maximum number of features to display per card (default 4) */
24
28
  maxFeatures?: number;
29
+
30
+ /** The viewer's plan, so their card reads "Current plan" and is disabled.
31
+ * Omit on signed-out pages. crochet_patterns-34n7. */
32
+ currentPlan?: CurrentPlanState | null;
25
33
  }
26
34
 
27
35
  export function PricingSection({
28
36
  productId,
29
37
  onSelectOffer,
30
38
  maxFeatures = 4,
39
+ currentPlan,
31
40
  }: PricingSectionProps) {
32
41
  const { product, loading, error } = useProduct(productId);
42
+ const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
43
+ product?.offers ?? [],
44
+ resolveCurrentOfferId(product?.offers ?? [], currentPlan),
45
+ );
33
46
 
34
47
  if (loading) {
35
48
  return (
@@ -55,32 +68,47 @@ export function PricingSection({
55
68
  );
56
69
  }
57
70
 
71
+ // One offer per tier for the chosen interval (crochet_patterns-34n7).
72
+ const visibleOffers = entries.map((e) => e.offer);
73
+ const currentOfferId = resolveCurrentOfferId(product.offers, currentPlan);
74
+
58
75
  return (
59
76
  <div data-testid="pricing-section" className="w-full px-4 py-8">
77
+ {hasChoice && (
78
+ <div className="mb-6">
79
+ <IntervalToggle interval={interval} onChange={setInterval} />
80
+ </div>
81
+ )}
60
82
  <div className="flex flex-row flex-wrap justify-center gap-4 items-stretch">
61
- {product.offers.map((offer) => (
62
- <SectionCard
63
- key={offer.id}
64
- offer={offer}
65
- onSelect={onSelectOffer}
66
- maxFeatures={maxFeatures}
67
- buildsOn={tierBelow(product.offers, offer)?.name ?? null}
68
- />
69
- ))}
83
+ {entries.map((entry) => {
84
+ const below = tierBelow(visibleOffers, entry.offer);
85
+ return (
86
+ <SectionCard
87
+ key={entry.offer.id}
88
+ entry={entry}
89
+ onSelect={onSelectOffer}
90
+ maxFeatures={maxFeatures}
91
+ buildsOn={below ? entries.find((e) => e.offer.id === below.id)?.tier.name ?? below.name : null}
92
+ isCurrent={entry.offer.id === currentOfferId}
93
+ />
94
+ );
95
+ })}
70
96
  </div>
71
97
  </div>
72
98
  );
73
99
  }
74
100
 
75
101
  interface SectionCardProps {
76
- offer: ProductOffer;
102
+ entry: IntervalEntry;
103
+ isCurrent?: boolean;
77
104
  onSelect?: (offer: ProductOffer) => void;
78
105
  maxFeatures: number;
79
106
  /** Name of the tier just below this one, when there is one. */
80
107
  buildsOn?: string | null;
81
108
  }
82
109
 
83
- function SectionCard({ offer, onSelect, maxFeatures, buildsOn }: SectionCardProps) {
110
+ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, isCurrent = false }: SectionCardProps) {
111
+ const { offer, tier } = entry;
84
112
  const isFeatured = offer.isFeatured;
85
113
  const visibleFeatures = offer.features.slice(0, maxFeatures);
86
114
 
@@ -96,19 +124,15 @@ function SectionCard({ offer, onSelect, maxFeatures, buildsOn }: SectionCardProp
96
124
  }
97
125
  `}
98
126
  >
99
- <h3 className="text-lg font-bold text-gray-900 mb-2">{offer.name}</h3>
127
+ <h3 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h3>
100
128
 
101
129
  <div className="mb-2">
102
- {offer.isFree ? (
103
- <span className="text-2xl font-extrabold text-gray-900">Free</span>
104
- ) : (
105
- <span>
106
- <span className="text-2xl font-extrabold text-gray-900">
107
- ${Math.floor(parseFloat(offer.unitPrice) || 0)}
108
- </span>
109
- <span className="text-sm text-gray-500 ml-1"> {offer.intervalDisplay}</span>
110
- </span>
111
- )}
130
+ <OfferPrice
131
+ offer={offer}
132
+ amountClassName="text-2xl font-extrabold text-gray-900"
133
+ intervalClassName="text-sm text-gray-500"
134
+ />
135
+ <AnnualNote entry={entry} className="mt-1" />
112
136
  </div>
113
137
 
114
138
  {offer.trialDays > 0 && (
@@ -132,32 +156,28 @@ function SectionCard({ offer, onSelect, maxFeatures, buildsOn }: SectionCardProp
132
156
  key={feature.key}
133
157
  className="flex items-center gap-2 py-1.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
134
158
  >
135
- <svg
136
- className="w-3.5 h-3.5 text-green-500 shrink-0"
137
- fill="none"
138
- viewBox="0 0 24 24"
139
- stroke="currentColor"
140
- strokeWidth={3}
141
- >
142
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
143
- </svg>
144
- <span>{feature.name}</span>
159
+ <FeatureIcon value={feature.value} size="w-3.5 h-3.5" />
160
+ <span>{formatFeatureLabel(feature)}</span>
145
161
  </li>
146
162
  ))}
147
163
  </ul>
148
164
 
149
165
  <button
150
- onClick={() => onSelect?.(offer)}
166
+ onClick={() => !isCurrent && onSelect?.(offer)}
151
167
  data-testid={`section-cta-${offer.slug}`}
168
+ disabled={isCurrent}
169
+ aria-disabled={isCurrent || undefined}
152
170
  className={`
153
- w-full py-2.5 px-4 text-sm font-bold rounded-lg cursor-pointer transition-colors
154
- ${isFeatured
155
- ? "bg-blue-600 text-white hover:bg-blue-700"
156
- : "bg-gray-100 text-gray-800 hover:bg-gray-200"
171
+ w-full py-2.5 px-4 text-sm font-bold rounded-lg transition-colors
172
+ ${isCurrent
173
+ ? "bg-gray-100 text-gray-500 cursor-not-allowed"
174
+ : isFeatured
175
+ ? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
176
+ : "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
157
177
  }
158
178
  `}
159
179
  >
160
- {offer.ctaText}
180
+ {isCurrent ? "Current plan" : offer.ctaText}
161
181
  </button>
162
182
  </div>
163
183
  );
@@ -408,7 +408,9 @@ describe("SubscriptionManager", () => {
408
408
  expect(screen.getByText(/manage billing/i)).toBeDefined();
409
409
  });
410
410
 
411
- it("shows No active subscription when no subscription exists", async () => {
411
+ // A signed-in user with no subscription row is on the free plan — the 404
412
+ // path only happens once the session is accepted (crochet_patterns-34n7).
413
+ it("shows the free plan as theirs when no subscription exists", async () => {
412
414
  const fetcher = mockFetchRouter({
413
415
  "/billing/subscription/current/": null, // 404 - no subscription
414
416
  "/billing/products/": mockProduct,
@@ -425,8 +427,9 @@ describe("SubscriptionManager", () => {
425
427
  );
426
428
 
427
429
  await waitFor(() => {
428
- expect(screen.getByText(/no active subscription/i)).toBeDefined();
430
+ expect(screen.getByText(/you're on the starter plan/i)).toBeDefined();
429
431
  });
432
+ expect(screen.queryByText(/no active subscription/i)).toBeNull();
430
433
  });
431
434
 
432
435
  it("shows subscribe options when no subscription", async () => {
@@ -591,7 +594,8 @@ describe("SubscriptionManager", () => {
591
594
  const trialNodes = screen.getAllByTestId("trial-copy");
592
595
  expect(trialNodes.length).toBe(1);
593
596
  expect(trialNodes[0].textContent).toMatch(/14-day free trial/i);
594
- expect(trialNodes[0].textContent).toMatch(/no card required/i);
597
+ // Checkout takes a card up front, so never promise otherwise (crochet_patterns-34n7).
598
+ expect(trialNodes[0].textContent).not.toMatch(/no card/i);
595
599
  });
596
600
 
597
601
  it("renders 'Unlimited X' for features with limit === -1 (o7u)", async () => {
@@ -631,8 +635,10 @@ describe("SubscriptionManager", () => {
631
635
 
632
636
  it("free-offer CTA calls subscribe-free, not Stripe checkout (6vk)", async () => {
633
637
  const onPlanChange = vi.fn();
638
+ // On Pro, so the Free card is a downgrade and clickable (with no
639
+ // subscription Free is the current plan and its CTA is disabled).
634
640
  const fetcher = mockFetchRouter({
635
- "/billing/subscription/current/": null,
641
+ "/billing/subscription/current/": mockSubscriptionActive,
636
642
  "/billing/products/": mockProduct,
637
643
  });
638
644
  const Wrapper = createWrapper(fetcher);
@@ -785,11 +791,12 @@ describe("SubscriptionManager", () => {
785
791
  };
786
792
 
787
793
  const cases: Case[] = [
788
- // No subscription (legacy / pre-auto-enrol): every card is an "upgrade"
789
- { label: "no-sub · Starter card → 'Start Free' (upgrade)",
794
+ // No subscription row (signed in, never enrolled): they are on the free
795
+ // plan, so it reads Current plan and is disabled (crochet_patterns-34n7).
796
+ { label: "no-sub · Starter card → 'Current plan' disabled badge",
790
797
  subscription: null, cardSlug: "starter-monthly",
791
- expectedCta: /start free/i, shouldBeDisabled: false,
792
- shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
798
+ expectedCta: /current plan/i, shouldBeDisabled: true,
799
+ shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
793
800
  { label: "no-sub · Pro card → 'Start Trial' (upgrade)",
794
801
  subscription: null, cardSlug: "pro-monthly",
795
802
  expectedCta: /start trial/i, shouldBeDisabled: false,
@@ -1274,9 +1281,9 @@ describe("SubscriptionManager", () => {
1274
1281
  </Wrapper>
1275
1282
  );
1276
1283
 
1277
- // No crash; falls through to the empty-state copy.
1284
+ // No crash; falls through to the free-plan copy.
1278
1285
  await waitFor(() =>
1279
- expect(screen.getByText("No active subscription")).toBeDefined()
1286
+ expect(screen.getByText(/you're on the starter plan/i)).toBeDefined()
1280
1287
  );
1281
1288
  });
1282
1289