@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.
- package/package.json +1 -1
- package/src/components/CurrentPlanCard.tsx +5 -10
- package/src/components/IntervalToggle.tsx +113 -0
- package/src/components/PlanPicker.crochet.test.tsx +322 -0
- package/src/components/PricingDetailPage.tsx +69 -41
- package/src/components/PricingPage.test.tsx +2 -2
- package/src/components/PricingPage.tsx +46 -24
- package/src/components/PricingSection.test.tsx +6 -5
- package/src/components/PricingSection.tsx +58 -38
- package/src/components/SubscriptionManager.test.tsx +17 -10
- package/src/components/SubscriptionManager.tsx +49 -40
- package/src/components/UpgradeModal.tsx +39 -21
- package/src/components/__fixtures__/crochetProduct.ts +172 -0
- package/src/index.ts +5 -2
- package/src/types/index.ts +4 -0
- package/src/utils/format.test.ts +57 -0
- package/src/utils/format.ts +54 -0
- package/src/utils/plan.ts +38 -0
- package/src/utils/tiers.interval.test.ts +76 -0
- package/src/utils/tiers.ts +105 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing monthly and annual offers into one card per tier
|
|
3
|
+
* (crochet_patterns-34n7).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { groupOffersByTier, offersForInterval, annualSavings } from "./tiers";
|
|
7
|
+
import {
|
|
8
|
+
crochetProduct,
|
|
9
|
+
crochetProductWithTiers,
|
|
10
|
+
crochetLite,
|
|
11
|
+
crochetLiteAnnual,
|
|
12
|
+
crochetPro,
|
|
13
|
+
} from "../components/__fixtures__/crochetProduct";
|
|
14
|
+
|
|
15
|
+
const slugs = (offers: { slug: string }[]) => offers.map((o) => o.slug);
|
|
16
|
+
|
|
17
|
+
describe("groupOffersByTier", () => {
|
|
18
|
+
it("pairs Lite with Lite Annual without a tier field (same features)", () => {
|
|
19
|
+
const tiers = groupOffersByTier(crochetProduct.offers);
|
|
20
|
+
expect(tiers.map((t) => t.name)).toEqual(["Free", "Lite", "Pro"]);
|
|
21
|
+
const lite = tiers[1];
|
|
22
|
+
expect(lite.monthly?.slug).toBe("crochet-lite");
|
|
23
|
+
expect(lite.annual?.slug).toBe("crochet-lite-annual");
|
|
24
|
+
expect(tiers[2].annual).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("pairs by tier when the API sends it", () => {
|
|
28
|
+
const tiers = groupOffersByTier(crochetProductWithTiers.offers);
|
|
29
|
+
expect(tiers.map((t) => [t.monthly?.slug ?? null, t.annual?.slug ?? null])).toEqual([
|
|
30
|
+
["crochet-free", null],
|
|
31
|
+
["crochet-lite", "crochet-lite-annual"],
|
|
32
|
+
["crochet-pro", "crochet-pro-annual"],
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("falls back to the name prefix when features differ", () => {
|
|
37
|
+
const annual = { ...crochetLiteAnnual, features: [] };
|
|
38
|
+
const tiers = groupOffersByTier([crochetLite, annual, crochetPro]);
|
|
39
|
+
expect(tiers.map((t) => t.name)).toEqual(["Lite", "Pro"]);
|
|
40
|
+
expect(tiers[0].annual?.slug).toBe("crochet-lite-annual");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("never pairs two monthly offers", () => {
|
|
44
|
+
const tiers = groupOffersByTier([crochetLite, crochetPro]);
|
|
45
|
+
expect(tiers).toHaveLength(2);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("offersForInterval", () => {
|
|
50
|
+
it("monthly shows Free, Lite, Pro", () => {
|
|
51
|
+
expect(slugs(offersForInterval(crochetProduct.offers, "monthly")))
|
|
52
|
+
.toEqual(["crochet-free", "crochet-lite", "crochet-pro"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("annual shows Free in both modes and keeps a tier with no annual price", () => {
|
|
56
|
+
expect(slugs(offersForInterval(crochetProduct.offers, "annual")))
|
|
57
|
+
.toEqual(["crochet-free", "crochet-lite-annual", "crochet-pro"]);
|
|
58
|
+
expect(slugs(offersForInterval(crochetProductWithTiers.offers, "annual")))
|
|
59
|
+
.toEqual(["crochet-free", "crochet-lite-annual", "crochet-pro-annual"]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("annualSavings", () => {
|
|
64
|
+
it("gives the per-month equivalent and the saving against 12x monthly", () => {
|
|
65
|
+
const lite = groupOffersByTier(crochetProduct.offers)[1];
|
|
66
|
+
const s = annualSavings(lite);
|
|
67
|
+
expect(s).not.toBeNull();
|
|
68
|
+
expect(s!.perMonth).toBeCloseTo(29 / 12, 5);
|
|
69
|
+
expect(s!.percent).toBe(19); // 29 vs 35.88
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("is null without a monthly sibling to compare against", () => {
|
|
73
|
+
const pro = groupOffersByTier(crochetProduct.offers)[2];
|
|
74
|
+
expect(annualSavings(pro)).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/utils/tiers.ts
CHANGED
|
@@ -53,3 +53,108 @@ export function tierBelow(offers: ProductOffer[], offer: ProductOffer): ProductO
|
|
|
53
53
|
const below = index > 0 ? cheapestFirst[index - 1] : null;
|
|
54
54
|
return below && below.features.length > 0 ? below : null;
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
/** Which price a plan picker is showing. */
|
|
58
|
+
export type BillingInterval = "monthly" | "annual";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One plan tier as the picker shows it: the same plan billed monthly and/or
|
|
62
|
+
* yearly. Free has only a monthly offer and shows in both modes.
|
|
63
|
+
*/
|
|
64
|
+
export interface OfferTier {
|
|
65
|
+
key: string;
|
|
66
|
+
/** The tier's name without the interval: "Lite", never "Lite Annual". */
|
|
67
|
+
name: string;
|
|
68
|
+
monthly: ProductOffer | null;
|
|
69
|
+
annual: ProductOffer | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const isAnnual = (o: ProductOffer) => o.intervalUnit === "year";
|
|
73
|
+
|
|
74
|
+
const featureSignature = (o: ProductOffer) =>
|
|
75
|
+
JSON.stringify(o.features.map((f) => [f.key, f.value, f.limit ?? null]));
|
|
76
|
+
|
|
77
|
+
const stripInterval = (name: string) =>
|
|
78
|
+
name.replace(/\s*[-·(]?\s*(annual|annually|yearly|year)\)?\s*$/i, "").trim() || name;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pair each annual offer with the monthly offer of the same plan
|
|
82
|
+
* (crochet_patterns-34n7). Evidence, strongest first: the API's `tier`, an
|
|
83
|
+
* identical feature list, then the annual name starting with the monthly
|
|
84
|
+
* name ("Lite Annual" → "Lite"). Two monthly offers are never paired, and an
|
|
85
|
+
* annual offer with no match becomes a tier of its own.
|
|
86
|
+
*/
|
|
87
|
+
export function groupOffersByTier(offers: ProductOffer[]): OfferTier[] {
|
|
88
|
+
const tiers: Array<OfferTier & { position: number }> = [];
|
|
89
|
+
|
|
90
|
+
offers.forEach((offer, position) => {
|
|
91
|
+
if (isAnnual(offer)) return;
|
|
92
|
+
tiers.push({
|
|
93
|
+
key: offer.tier ? `tier:${offer.tier}` : `offer:${offer.slug}`,
|
|
94
|
+
name: offer.name,
|
|
95
|
+
monthly: offer,
|
|
96
|
+
annual: null,
|
|
97
|
+
position,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
offers.forEach((offer, position) => {
|
|
102
|
+
if (!isAnnual(offer)) return;
|
|
103
|
+
const open = tiers.filter((t) => t.annual === null && t.monthly && !t.monthly.isFree);
|
|
104
|
+
const signature = featureSignature(offer);
|
|
105
|
+
const lowerName = offer.name.toLowerCase();
|
|
106
|
+
const match =
|
|
107
|
+
(offer.tier ? open.find((t) => t.monthly?.tier === offer.tier) : undefined) ??
|
|
108
|
+
open.find((t) => offer.features.length > 0 && featureSignature(t.monthly!) === signature) ??
|
|
109
|
+
open
|
|
110
|
+
.filter((t) => lowerName.startsWith(`${t.monthly!.name.toLowerCase()} `))
|
|
111
|
+
.sort((a, b) => b.monthly!.name.length - a.monthly!.name.length)[0];
|
|
112
|
+
|
|
113
|
+
if (match) {
|
|
114
|
+
match.annual = offer;
|
|
115
|
+
} else {
|
|
116
|
+
tiers.push({
|
|
117
|
+
key: offer.tier ? `tier:${offer.tier}` : `offer:${offer.slug}`,
|
|
118
|
+
name: stripInterval(offer.name),
|
|
119
|
+
monthly: null,
|
|
120
|
+
annual: offer,
|
|
121
|
+
position,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return tiers
|
|
127
|
+
.sort((a, b) => a.position - b.position)
|
|
128
|
+
.map(({ position: _position, ...tier }) => tier);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The offer a tier shows for `interval`, falling back to the other one so a
|
|
132
|
+
* plan with no annual price stays on the page. */
|
|
133
|
+
export function tierOffer(tier: OfferTier, interval: BillingInterval): ProductOffer {
|
|
134
|
+
return (interval === "annual" ? tier.annual ?? tier.monthly : tier.monthly ?? tier.annual)!;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One offer per tier for the chosen interval, in catalogue order. */
|
|
138
|
+
export function offersForInterval(offers: ProductOffer[], interval: BillingInterval): ProductOffer[] {
|
|
139
|
+
return groupOffersByTier(offers).map((tier) => tierOffer(tier, interval));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** True when at least one plan can be bought both monthly and yearly. */
|
|
143
|
+
export function hasIntervalChoice(offers: ProductOffer[]): boolean {
|
|
144
|
+
return groupOffersByTier(offers).some((t) => t.monthly !== null && t.annual !== null);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What yearly billing works out to per month, and the whole-percent saving
|
|
149
|
+
* against paying monthly for twelve months. Null when the tier has no
|
|
150
|
+
* monthly price to compare against.
|
|
151
|
+
*/
|
|
152
|
+
export function annualSavings(tier: OfferTier): { perMonth: number; percent: number } | null {
|
|
153
|
+
if (!tier.monthly || !tier.annual) return null;
|
|
154
|
+
const yearly = parseFloat(tier.annual.unitPrice);
|
|
155
|
+
const monthly = parseFloat(tier.monthly.unitPrice);
|
|
156
|
+
if (!Number.isFinite(yearly) || !Number.isFinite(monthly) || monthly <= 0) return null;
|
|
157
|
+
const twelve = monthly * 12;
|
|
158
|
+
const percent = yearly < twelve ? Math.floor(((twelve - yearly) / twelve) * 100) : 0;
|
|
159
|
+
return { perMonth: yearly / 12, percent };
|
|
160
|
+
}
|