@startsimpli/billing 0.4.27 → 0.4.28

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/billing",
3
- "version": "0.4.27",
3
+ "version": "0.4.28",
4
4
  "description": "Universal billing integration for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -99,14 +99,9 @@ export function OfferPrice({
99
99
  amountClassName: string;
100
100
  intervalClassName?: string;
101
101
  }) {
102
- if (offer.isFree) {
103
- return (
104
- <span aria-label="Free">
105
- <span aria-hidden="true" className={amountClassName}>Free</span>
106
- </span>
107
- );
108
- }
109
- const price = formatPrice(offer.unitPrice, offer.currency);
102
+ // A free plan reads "$0 per month" like the others; its name already says
103
+ // Free, so the price does not repeat it (crochet_patterns-vveb).
104
+ const price = formatPrice(offer.isFree ? 0 : offer.unitPrice, offer.currency);
110
105
  return (
111
106
  <span aria-label={`${price} ${offer.intervalDisplay}`}>
112
107
  <span aria-hidden="true" className={amountClassName}>{price}</span>
@@ -11,7 +11,7 @@
11
11
  import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
13
  import type { PlanChangePreview, ProductOffer, OfferFeature } from "../types";
14
- import { cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
14
+ import { cardFeatures, cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
15
15
  import { formatFeatureLabel } from "../utils/format";
16
16
  import {
17
17
  isPayingSubscription,
@@ -20,7 +20,6 @@ import {
20
20
  planDirection,
21
21
  resolveCurrentOffer,
22
22
  trialApplies,
23
- TRIAL_NOTE_FEATURE_KEY,
24
23
  type CurrentPlanState,
25
24
  } from "../utils/plan";
26
25
  import { usePlanChange } from "../hooks/usePlanChange";
@@ -280,10 +279,6 @@ interface DetailCardProps {
280
279
  busy?: boolean;
281
280
  }
282
281
 
283
- function sameFeature(a: OfferFeature, b: OfferFeature): boolean {
284
- return a.value === b.value && (a.limit ?? null) === (b.limit ?? null);
285
- }
286
-
287
282
  function DetailCard({
288
283
  entry, onSelect, buildsOn, inherited, yourPlanNote, isCurrent = false, ctaLabel, showTrial, busy = false, spinning = false,
289
284
  }: DetailCardProps) {
@@ -292,12 +287,7 @@ function DetailCard({
292
287
  // Each row once (crochet_patterns-tv7fb): no "not included" rows (the
293
288
  // comparison table has them), no trial row (the pill says it, or the trial
294
289
  // does not apply), and nothing the tier below already has.
295
- const features = offer.features.filter((f) => {
296
- if (f.value === false) return false;
297
- if (f.key === TRIAL_NOTE_FEATURE_KEY) return false;
298
- const below = inherited?.[f.key];
299
- return !(below && sameFeature(below, f));
300
- });
290
+ const features = cardFeatures(offer, inherited);
301
291
  // Featured is the tier's, so Pro stays highlighted on Annual too
302
292
  // (crochet_patterns-ftrka).
303
293
  const isFeatured = !!(tier.monthly?.isFeatured || tier.annual?.isFeatured);
@@ -308,13 +298,13 @@ function DetailCard({
308
298
  className={`
309
299
  flex flex-col relative rounded-2xl p-8 w-full max-w-sm flex-1 min-w-[280px]
310
300
  ${isFeatured
311
- ? "border-2 border-blue-600 shadow-lg"
301
+ ? "border-2 border-[color:var(--billing-accent,#2563eb)] shadow-lg"
312
302
  : "border border-gray-200 bg-white shadow-sm"
313
303
  }
314
304
  `}
315
305
  >
316
306
  {isFeatured && (
317
- <span className="absolute -top-3.5 left-1/2 -translate-x-1/2 bg-blue-600 text-white text-xs font-bold px-4 py-1 rounded-full uppercase tracking-wide whitespace-nowrap">
307
+ <span className="absolute -top-3.5 left-1/2 -translate-x-1/2 bg-[var(--billing-accent,#2563eb)] text-white text-xs font-bold px-4 py-1 rounded-full uppercase tracking-wide whitespace-nowrap">
318
308
  Most Popular
319
309
  </span>
320
310
  )}
@@ -336,7 +326,7 @@ function DetailCard({
336
326
 
337
327
  <div className="mb-5 min-h-8">
338
328
  {trial ? (
339
- <span data-testid={`trial-pill-${offer.slug}`} className="inline-block bg-blue-50 text-blue-700 text-sm font-semibold px-3 py-1 rounded-lg w-fit">
329
+ <span data-testid={`trial-pill-${offer.slug}`} className="inline-block bg-[var(--billing-accent-soft,#eff6ff)] text-[color:var(--billing-accent-text,#1d4ed8)] text-sm font-semibold px-3 py-1 rounded-lg w-fit">
340
330
  {offer.trialDays}-day free trial
341
331
  </span>
342
332
  ) : yourPlanNote ? (
@@ -376,7 +366,7 @@ function DetailCard({
376
366
  ${isCurrent
377
367
  ? "bg-gray-100 text-gray-500 cursor-not-allowed"
378
368
  : isFeatured
379
- ? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer disabled:opacity-70 disabled:cursor-wait"
369
+ ? "bg-[var(--billing-accent,#2563eb)] text-white hover:bg-[var(--billing-accent-hover,#1d4ed8)] cursor-pointer disabled:opacity-70 disabled:cursor-wait"
380
370
  : "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer disabled:opacity-70 disabled:cursor-wait"
381
371
  }
382
372
  `}
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The compact PricingSection (crochet Home) follows the same card rules as the
3
+ * full pricing page (design review, Home page):
4
+ * - Under "Everything in X, plus:" a card lists only what it adds: no rows the
5
+ * tier below has, no "not included" rows, no trial row (crochet_patterns-469n).
6
+ * - Every card reserves the pill and lead-in rows (crochet_patterns-gac4).
7
+ * - A free plan's price reads "$0 per month", not "Free" twice (crochet_patterns-vveb).
8
+ * - The highlight colour comes from --billing-accent so an app can theme it
9
+ * (crochet_patterns-fpv4); blue stays the default.
10
+ */
11
+ import React from "react";
12
+ import { describe, it, expect } from "vitest";
13
+ import { render, screen, waitFor, within } from "@testing-library/react";
14
+ import { BillingProvider } from "../hooks/BillingProvider";
15
+ import { PricingSection } from "./PricingSection";
16
+
17
+ const offer = (o: Record<string, unknown>) => ({
18
+ currency: "USD", pricingModel: "flat", intervalUnit: "month", intervalCount: 1,
19
+ intervalDisplay: "per month", isRecurring: true, isActive: true, isFeatured: false,
20
+ trialDays: 0, ctaText: "Choose", description: "", ...o,
21
+ });
22
+
23
+ // The live crochet-patterns offers (api.startsimpli.com, 2026-10-18), monthly only.
24
+ const product = {
25
+ id: "p", slug: "crochet-patterns", name: "Crochet Patterns", description: "",
26
+ offers: [
27
+ offer({ id: "f", slug: "crochet-free", name: "Free", unitPrice: "0.00", isFree: true, sortOrder: 0, features: [
28
+ { key: "ai_designs", name: "AI pattern designs", value: false },
29
+ { key: "browse_patterns", name: "Browse and buy patterns", value: true },
30
+ { key: "save_patterns", name: "Save patterns to your library", value: true },
31
+ { key: "track_progress", name: "Track your progress", value: true },
32
+ { key: "pdf_export", name: "Print-ready PDF export", value: true, limit: null },
33
+ ] }),
34
+ offer({ id: "l", slug: "crochet-lite", name: "Lite", unitPrice: "2.99", isFree: false, sortOrder: 1, features: [
35
+ { key: "ai_designs", name: "AI pattern designs", value: true },
36
+ { key: "ai_designs_per_month", name: "AI designs per month", value: 3 },
37
+ { key: "ai_refine", name: "AI chat to refine any pattern", value: true },
38
+ { key: "pdf_export", name: "Print-ready PDF export", value: true },
39
+ { key: "save_patterns", name: "Save patterns to your library", value: true },
40
+ { key: "track_progress", name: "Track your progress", value: true },
41
+ { key: "patterns_per_month", name: "Saved patterns", value: -1 },
42
+ ] }),
43
+ offer({ id: "pr", slug: "crochet-pro", name: "Pro", unitPrice: "9.00", isFree: false, sortOrder: 2, isFeatured: true, trialDays: 7, features: [
44
+ { key: "ai_designs", name: "AI pattern designs, made with our best model and checked by a design review", value: true },
45
+ { key: "patterns_per_month", name: "Saved patterns", value: -1 },
46
+ { key: "ai_refine", name: "AI chat to refine any pattern", value: true },
47
+ { key: "pdf_export", name: "Print-ready PDF export", value: true },
48
+ { key: "custom_sizing", name: "Custom sizing", value: true },
49
+ { key: "trial_note", name: "7-day free trial with AI designs included", value: true },
50
+ { key: "ai_designs_per_month", name: "AI designs per month", value: 15 },
51
+ ] }),
52
+ ],
53
+ };
54
+
55
+ function renderSection() {
56
+ const fetcher = (async () => new Response(JSON.stringify(product), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch;
57
+ return render(
58
+ <BillingProvider apiBaseUrl="https://api.test.com/api/v1" fetcher={fetcher}>
59
+ <PricingSection productId="crochet-patterns" maxFeatures={4} />
60
+ </BillingProvider>,
61
+ );
62
+ }
63
+
64
+ const rows = (slug: string) =>
65
+ within(screen.getByTestId(`section-card-${slug}`)).getAllByRole("listitem").map((li) => li.textContent?.trim());
66
+
67
+ describe("PricingSection card rules", () => {
68
+ it("Pro lists only what Lite lacks, so 15 designs and Custom sizing are not cut by the 4-row cap", async () => {
69
+ renderSection();
70
+ await waitFor(() => screen.getByTestId("section-card-crochet-pro"));
71
+ const pro = rows("crochet-pro");
72
+ expect(pro.some((r) => /15/.test(r ?? ""))).toBe(true);
73
+ expect(pro).toContain("Custom sizing");
74
+ expect(pro).not.toContain("AI chat to refine any pattern");
75
+ expect(pro).not.toContain("Print-ready PDF export");
76
+ expect(pro.some((r) => /7-day free trial with/.test(r ?? ""))).toBe(false);
77
+ });
78
+
79
+ it("Free drops its not-included row; Lite does not repeat Free's rows", async () => {
80
+ renderSection();
81
+ await waitFor(() => screen.getByTestId("section-card-crochet-lite"));
82
+ expect(rows("crochet-free")).not.toContain("AI pattern designs");
83
+ const lite = rows("crochet-lite");
84
+ expect(lite).not.toContain("Print-ready PDF export");
85
+ expect(lite).not.toContain("Track your progress");
86
+ });
87
+
88
+ it("every card reserves the lead-in row, and the free price is $0 per month", async () => {
89
+ renderSection();
90
+ await waitFor(() => screen.getByTestId("section-card-crochet-free"));
91
+ for (const slug of ["crochet-free", "crochet-lite", "crochet-pro"]) {
92
+ expect(screen.getByTestId(`section-lead-${slug}`)).toBeDefined();
93
+ }
94
+ expect(screen.getByTestId("section-lead-crochet-free").textContent).toBe("");
95
+ const free = screen.getByTestId("section-card-crochet-free");
96
+ expect(within(free).getByLabelText("$0 per month")).toBeDefined();
97
+ expect(within(free).getAllByText("Free")).toHaveLength(1); // the name only
98
+ });
99
+
100
+ it("the featured card and its button use the themeable accent", async () => {
101
+ renderSection();
102
+ await waitFor(() => screen.getByTestId("section-card-crochet-pro"));
103
+ expect(screen.getByTestId("section-card-crochet-pro").className).toContain("var(--billing-accent,#2563eb)");
104
+ expect(screen.getByTestId("section-cta-crochet-pro").className).toContain("bg-[var(--billing-accent,#2563eb)]");
105
+ });
106
+ });
@@ -221,7 +221,9 @@ describe("PricingSection", () => {
221
221
  // "Up to 1 team seats" since crochet_patterns-34n7, so match the noun.
222
222
  // We should see "Team seats" and "Projects" (first 2), but not "Storage"
223
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
+ // Enterprise's "Unlimited" projects is Pro's too, so it is not repeated
225
+ // under "Everything in Pro, plus:" (crochet_patterns-469n).
226
+ expect(screen.getAllByText(/projects$/i).length).toBe(2);
225
227
  // Storage should appear fewer times or not at all since we're limiting to 2 features per card
226
228
  const storageElements = screen.queryAllByText("Storage");
227
229
  expect(storageElements.length).toBeLessThan(3); // Should not appear in all cards
@@ -242,7 +244,7 @@ describe("PricingSection", () => {
242
244
 
243
245
  // With default maxFeatures=4, should show first 4 features
244
246
  expect(screen.getAllByText(/team seats$/i).length).toBe(3);
245
- expect(screen.getAllByText(/projects$/i).length).toBe(3);
247
+ expect(screen.getAllByText(/projects$/i).length).toBe(2); // Enterprise does not repeat Pro's Unlimited
246
248
  expect(screen.getAllByText("Storage").length).toBe(3);
247
249
  expect(screen.getAllByText("Support").length).toBe(3);
248
250
  });
@@ -10,8 +10,8 @@
10
10
 
11
11
  import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
- import type { ProductOffer } from "../types";
14
- import { tierBelow } from "../utils/tiers";
13
+ import type { OfferFeature, ProductOffer } from "../types";
14
+ import { cardFeatures, cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
15
15
  import { FeatureIcon } from "./FeatureIcon";
16
16
  import { formatFeatureLabel } from "../utils/format";
17
17
  import { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
@@ -71,6 +71,9 @@ export function PricingSection({
71
71
  // One offer per tier for the chosen interval (crochet_patterns-34n7).
72
72
  const visibleOffers = entries.map((e) => e.offer);
73
73
  const currentOfferId = resolveCurrentOfferId(product.offers, currentPlan);
74
+ // What each tier already has, so a card under "Everything in X, plus:" lists
75
+ // only what it adds, as on the full pricing page (crochet_patterns-469n).
76
+ const cumulative = cumulativeFeatureLookup(visibleOffers);
74
77
 
75
78
  return (
76
79
  <div data-testid="pricing-section" className="w-full px-4 py-8">
@@ -89,6 +92,7 @@ export function PricingSection({
89
92
  onSelect={onSelectOffer}
90
93
  maxFeatures={maxFeatures}
91
94
  buildsOn={below ? entries.find((e) => e.offer.id === below.id)?.tier.name ?? below.name : null}
95
+ inherited={below ? cumulative[below.slug] ?? null : null}
92
96
  isCurrent={entry.offer.id === currentOfferId}
93
97
  />
94
98
  );
@@ -105,12 +109,15 @@ interface SectionCardProps {
105
109
  maxFeatures: number;
106
110
  /** Name of the tier just below this one, when there is one. */
107
111
  buildsOn?: string | null;
112
+ /** What the tier below already has (cumulative); rows it matches are not repeated. */
113
+ inherited?: Record<string, OfferFeature> | null;
108
114
  }
109
115
 
110
- function SectionCard({ entry, onSelect, maxFeatures, buildsOn, isCurrent = false }: SectionCardProps) {
116
+ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, inherited, isCurrent = false }: SectionCardProps) {
111
117
  const { offer, tier } = entry;
112
- const isFeatured = offer.isFeatured;
113
- const visibleFeatures = offer.features.slice(0, maxFeatures);
118
+ // Featured is the tier's, so the highlight survives the Annual toggle.
119
+ const isFeatured = !!(tier.monthly?.isFeatured || tier.annual?.isFeatured);
120
+ const visibleFeatures = cardFeatures(offer, inherited).slice(0, maxFeatures);
114
121
 
115
122
  return (
116
123
  <div
@@ -119,7 +126,7 @@ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, isCurrent = false
119
126
  className={`
120
127
  flex flex-col relative rounded-xl p-6 flex-1 min-w-[220px] max-w-xs
121
128
  ${isFeatured
122
- ? "border-2 border-blue-600 shadow-md"
129
+ ? "border-2 border-[color:var(--billing-accent,#2563eb)] shadow-md"
123
130
  : "border border-gray-200 bg-white shadow-sm"
124
131
  }
125
132
  `}
@@ -132,23 +139,27 @@ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, isCurrent = false
132
139
  amountClassName="text-2xl font-extrabold text-gray-900"
133
140
  intervalClassName="text-sm text-gray-500"
134
141
  />
135
- <AnnualNote entry={entry} className="mt-1" />
142
+ <div className="mt-1 min-h-4">
143
+ <AnnualNote entry={entry} />
144
+ </div>
136
145
  </div>
137
146
 
138
- {offer.trialDays > 0 && (
139
- <span
140
- data-testid="trial-badge"
141
- className="inline-block bg-blue-50 text-blue-700 text-xs font-semibold px-2 py-0.5 rounded-md mb-3 w-fit"
142
- >
143
- {offer.trialDays}-day free trial
144
- </span>
145
- )}
147
+ {/* Every card reserves the pill and lead-in rows, so matching features
148
+ start at the same height on each card (crochet_patterns-gac4). */}
149
+ <div className="mb-3 min-h-5">
150
+ {offer.trialDays > 0 && (
151
+ <span
152
+ data-testid="trial-badge"
153
+ className="inline-block bg-[var(--billing-accent-soft,#eff6ff)] text-[color:var(--billing-accent-text,#1d4ed8)] text-xs font-semibold px-2 py-0.5 rounded-md w-fit"
154
+ >
155
+ {offer.trialDays}-day free trial
156
+ </span>
157
+ )}
158
+ </div>
146
159
 
147
- {buildsOn && (
148
- <p className="text-sm font-semibold text-gray-900 mb-1">
149
- Everything in {buildsOn}, plus:
150
- </p>
151
- )}
160
+ <p data-testid={`section-lead-${offer.slug}`} className="text-sm font-semibold text-gray-900 mb-1 min-h-5">
161
+ {buildsOn ? `Everything in ${buildsOn}, plus:` : null}
162
+ </p>
152
163
 
153
164
  <ul className="list-none p-0 m-0 mb-4 flex-1 space-y-0">
154
165
  {visibleFeatures.map((feature) => (
@@ -172,7 +183,7 @@ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, isCurrent = false
172
183
  ${isCurrent
173
184
  ? "bg-gray-100 text-gray-500 cursor-not-allowed"
174
185
  : isFeatured
175
- ? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
186
+ ? "bg-[var(--billing-accent,#2563eb)] text-white hover:bg-[var(--billing-accent-hover,#1d4ed8)] cursor-pointer"
176
187
  : "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
177
188
  }
178
189
  `}
@@ -6,6 +6,7 @@
6
6
  * showed a paid plan as missing what the free plan had.
7
7
  */
8
8
  import type { ProductOffer, OfferFeature } from "../types";
9
+ import { TRIAL_NOTE_FEATURE_KEY } from "./plan";
9
10
  /**
10
11
  * Plans are cumulative: a paid tier includes what the cheaper tier includes.
11
12
  *
@@ -158,3 +159,25 @@ export function annualSavings(tier: OfferTier): { perMonth: number; percent: num
158
159
  const percent = yearly < twelve ? Math.floor(((twelve - yearly) / twelve) * 100) : 0;
159
160
  return { perMonth: yearly / 12, percent };
160
161
  }
162
+
163
+ function sameFeature(a: OfferFeature, b: OfferFeature): boolean {
164
+ return a.value === b.value && (a.limit ?? null) === (b.limit ?? null);
165
+ }
166
+
167
+ /**
168
+ * The rows a pricing card lists (crochet_patterns-tv7fb, -469n): no "not
169
+ * included" rows, no trial row (the pill says it), and nothing the tier below
170
+ * already has, so a card under "Everything in X, plus:" lists only what it adds.
171
+ * Shared by the full pricing page and the compact section on landing pages.
172
+ */
173
+ export function cardFeatures(
174
+ offer: ProductOffer,
175
+ inherited: Record<string, OfferFeature> | null | undefined,
176
+ ): OfferFeature[] {
177
+ return offer.features.filter((f) => {
178
+ if (f.value === false) return false;
179
+ if (f.key === TRIAL_NOTE_FEATURE_KEY) return false;
180
+ const below = inherited?.[f.key];
181
+ return !(below && sameFeature(below, f));
182
+ });
183
+ }