@startsimpli/billing 0.4.23 → 0.4.25

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,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
  );
@@ -376,12 +376,15 @@ describe("SubscriptionManager", () => {
376
376
  expect(screen.getByTestId("available-plans")).toBeDefined();
377
377
  });
378
378
 
379
- // Click Enterprise upgrade option
379
+ // Click Enterprise upgrade option: already paying, so it asks first.
380
380
  const enterpriseButton = screen.getByTestId("upgrade-cta-enterprise-monthly");
381
381
  await userEvent.click(enterpriseButton);
382
+ expect(onPlanChange).not.toHaveBeenCalled();
383
+ await userEvent.click(await screen.findByTestId("plan-change-confirm-button"));
382
384
 
383
385
  expect(onPlanChange).toHaveBeenCalledWith(
384
- expect.objectContaining({ slug: "enterprise-monthly" })
386
+ expect.objectContaining({ slug: "enterprise-monthly" }),
387
+ null,
385
388
  );
386
389
  });
387
390
 
@@ -408,7 +411,9 @@ describe("SubscriptionManager", () => {
408
411
  expect(screen.getByText(/manage billing/i)).toBeDefined();
409
412
  });
410
413
 
411
- it("shows No active subscription when no subscription exists", async () => {
414
+ // A signed-in user with no subscription row is on the free plan — the 404
415
+ // path only happens once the session is accepted (crochet_patterns-34n7).
416
+ it("shows the free plan as theirs when no subscription exists", async () => {
412
417
  const fetcher = mockFetchRouter({
413
418
  "/billing/subscription/current/": null, // 404 - no subscription
414
419
  "/billing/products/": mockProduct,
@@ -425,8 +430,9 @@ describe("SubscriptionManager", () => {
425
430
  );
426
431
 
427
432
  await waitFor(() => {
428
- expect(screen.getByText(/no active subscription/i)).toBeDefined();
433
+ expect(screen.getByText(/you're on the starter plan/i)).toBeDefined();
429
434
  });
435
+ expect(screen.queryByText(/no active subscription/i)).toBeNull();
430
436
  });
431
437
 
432
438
  it("shows subscribe options when no subscription", async () => {
@@ -591,7 +597,8 @@ describe("SubscriptionManager", () => {
591
597
  const trialNodes = screen.getAllByTestId("trial-copy");
592
598
  expect(trialNodes.length).toBe(1);
593
599
  expect(trialNodes[0].textContent).toMatch(/14-day free trial/i);
594
- expect(trialNodes[0].textContent).toMatch(/no card required/i);
600
+ // Checkout takes a card up front, so never promise otherwise (crochet_patterns-34n7).
601
+ expect(trialNodes[0].textContent).not.toMatch(/no card/i);
595
602
  });
596
603
 
597
604
  it("renders 'Unlimited X' for features with limit === -1 (o7u)", async () => {
@@ -631,8 +638,10 @@ describe("SubscriptionManager", () => {
631
638
 
632
639
  it("free-offer CTA calls subscribe-free, not Stripe checkout (6vk)", async () => {
633
640
  const onPlanChange = vi.fn();
641
+ // On Pro, so the Free card is a downgrade and clickable (with no
642
+ // subscription Free is the current plan and its CTA is disabled).
634
643
  const fetcher = mockFetchRouter({
635
- "/billing/subscription/current/": null,
644
+ "/billing/subscription/current/": mockSubscriptionActive,
636
645
  "/billing/products/": mockProduct,
637
646
  });
638
647
  const Wrapper = createWrapper(fetcher);
@@ -649,8 +658,10 @@ describe("SubscriptionManager", () => {
649
658
 
650
659
  await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
651
660
 
652
- // Click the Free card's CTA (slug "starter-monthly", isFree=true)
653
- (await screen.findByTestId("upgrade-cta-starter-monthly")).click();
661
+ // Click the Free card's CTA (slug "starter-monthly", isFree=true), then
662
+ // confirm: leaving a paid plan is never one click (crochet_patterns-lr7mj).
663
+ await userEvent.click(await screen.findByTestId("upgrade-cta-starter-monthly"));
664
+ await userEvent.click(await screen.findByTestId("plan-change-confirm-button"));
654
665
 
655
666
  // subscribe-free is hit (server fetcher saw the POST)
656
667
  await waitFor(() => {
@@ -785,11 +796,12 @@ describe("SubscriptionManager", () => {
785
796
  };
786
797
 
787
798
  const cases: Case[] = [
788
- // No subscription (legacy / pre-auto-enrol): every card is an "upgrade"
789
- { label: "no-sub · Starter card → 'Start Free' (upgrade)",
799
+ // No subscription row (signed in, never enrolled): they are on the free
800
+ // plan, so it reads Current plan and is disabled (crochet_patterns-34n7).
801
+ { label: "no-sub · Starter card → 'Current plan' disabled badge",
790
802
  subscription: null, cardSlug: "starter-monthly",
791
- expectedCta: /start free/i, shouldBeDisabled: false,
792
- shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
803
+ expectedCta: /current plan/i, shouldBeDisabled: true,
804
+ shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
793
805
  { label: "no-sub · Pro card → 'Start Trial' (upgrade)",
794
806
  subscription: null, cardSlug: "pro-monthly",
795
807
  expectedCta: /start trial/i, shouldBeDisabled: false,
@@ -823,10 +835,12 @@ describe("SubscriptionManager", () => {
823
835
  cardSlug: "pro-monthly",
824
836
  expectedCta: /current plan/i, shouldBeDisabled: true,
825
837
  shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
826
- { label: "pro · Enterprise card → upgrade CTA, trial copy if applicable",
838
+ // Already paying: an upgrade changes the plan in place, so it says so
839
+ // instead of the new-buyer pitch (crochet_patterns-anw61).
840
+ { label: "pro · Enterprise card → 'Upgrade to Enterprise', no trial copy",
827
841
  subscription: mockSubscriptionActive,
828
842
  cardSlug: "enterprise-monthly",
829
- expectedCta: /contact sales/i, shouldBeDisabled: false,
843
+ expectedCta: /upgrade to enterprise/i, shouldBeDisabled: false,
830
844
  shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
831
845
 
832
846
  // TRIALING Pro current sub
@@ -958,8 +972,10 @@ describe("SubscriptionManager", () => {
958
972
  </Wrapper>
959
973
  );
960
974
 
961
- // Banner renders
962
- await waitFor(() => expect(screen.getByTestId("status-banner-success")).toBeDefined());
975
+ // No subscription found yet: "Activating…", never "activated" beside
976
+ // Free (crochet_patterns-nag60).
977
+ await waitFor(() => expect(screen.getByTestId("status-banner-activating")).toBeDefined());
978
+ expect(screen.queryByTestId("status-banner-success")).toBeNull();
963
979
 
964
980
  // success-sync was hit
965
981
  await waitFor(() => {
@@ -986,7 +1002,10 @@ describe("SubscriptionManager", () => {
986
1002
  </Wrapper>
987
1003
  );
988
1004
 
989
- await waitFor(() => expect(screen.getByTestId("status-banner-success")).toBeDefined());
1005
+ await waitFor(() => {
1006
+ const calls = (fetcher as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
1007
+ expect(calls.some((u) => u.includes("/billing/success-sync/"))).toBe(true);
1008
+ });
990
1009
  rerender(
991
1010
  <Wrapper>
992
1011
  <SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" statusMessage="success" />
@@ -1274,9 +1293,9 @@ describe("SubscriptionManager", () => {
1274
1293
  </Wrapper>
1275
1294
  );
1276
1295
 
1277
- // No crash; falls through to the empty-state copy.
1296
+ // No crash; falls through to the free-plan copy.
1278
1297
  await waitFor(() =>
1279
- expect(screen.getByText("No active subscription")).toBeDefined()
1298
+ expect(screen.getByText(/you're on the starter plan/i)).toBeDefined()
1280
1299
  );
1281
1300
  });
1282
1301