@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.
- 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 +6 -0
- package/src/utils/api.test.ts +26 -0
- package/src/utils/api.ts +5 -0
- package/src/utils/format.test.ts +57 -0
- package/src/utils/format.ts +54 -0
- package/src/utils/ga-ids.test.ts +23 -0
- package/src/utils/ga-ids.ts +43 -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,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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readGaIds } from "./ga-ids";
|
|
3
|
+
|
|
4
|
+
describe("readGaIds", () => {
|
|
5
|
+
it("reads the client id from _ga and the session id from a GS2 _ga_<stream> cookie", () => {
|
|
6
|
+
expect(
|
|
7
|
+
readGaIds("foo=1; _ga=GA1.1.1234567890.1760000000; _ga_K1M560RH1W=GS2.1.s1760600000$o3$g1$t1760600500$j0$l0$h0"),
|
|
8
|
+
).toEqual({ ga_client_id: "1234567890.1760000000", ga_session_id: "1760600000" });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("reads the session id from the older GS1 format", () => {
|
|
12
|
+
expect(readGaIds("_ga=GA1.2.111.222; _ga_ABC=GS1.1.1760600001.4.1.1760600500.0.0.0")).toEqual({
|
|
13
|
+
ga_client_id: "111.222",
|
|
14
|
+
ga_session_id: "1760600001",
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns a client id without a session, and nothing without _ga", () => {
|
|
19
|
+
expect(readGaIds("_ga=GA1.1.111.222")).toEqual({ ga_client_id: "111.222", ga_session_id: "" });
|
|
20
|
+
expect(readGaIds("other=1")).toBeNull();
|
|
21
|
+
expect(readGaIds("")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buyer's GA4 client and session ids, read from gtag's cookies, so the
|
|
3
|
+
* server can send the subscription's conversion to the same GA user and
|
|
4
|
+
* session (and Google Ads can credit the ad click). Sent with checkout.
|
|
5
|
+
*
|
|
6
|
+
* _ga=GA1.1.<client id: two numbers>
|
|
7
|
+
* _ga_<stream>=GS1.1.<session id>.<...> (before 2025)
|
|
8
|
+
* _ga_<stream>=GS2.1.s<session id>$o<n>$... (since 2025)
|
|
9
|
+
*/
|
|
10
|
+
export interface GaIds {
|
|
11
|
+
ga_client_id: string;
|
|
12
|
+
ga_session_id: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readGaIds(cookie: string): GaIds | null {
|
|
16
|
+
const jar = new Map<string, string>();
|
|
17
|
+
for (const part of cookie.split(";")) {
|
|
18
|
+
const i = part.indexOf("=");
|
|
19
|
+
if (i > 0) jar.set(part.slice(0, i).trim(), decodeURIComponent(part.slice(i + 1).trim()));
|
|
20
|
+
}
|
|
21
|
+
const ga = jar.get("_ga")?.split(".");
|
|
22
|
+
if (!ga || ga.length < 4) return null;
|
|
23
|
+
const clientId = ga.slice(-2).join(".");
|
|
24
|
+
|
|
25
|
+
let sessionId = "";
|
|
26
|
+
for (const [name, value] of jar) {
|
|
27
|
+
if (!name.startsWith("_ga_")) continue;
|
|
28
|
+
const gs2 = value.match(/^GS2\.\d+\.s(\d+)/);
|
|
29
|
+
const gs1 = value.match(/^GS1\.\d+\.(\d+)\./);
|
|
30
|
+
sessionId = gs2?.[1] ?? gs1?.[1] ?? "";
|
|
31
|
+
if (sessionId) break;
|
|
32
|
+
}
|
|
33
|
+
return { ga_client_id: clientId, ga_session_id: sessionId };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** readGaIds over document.cookie; null outside a browser. */
|
|
37
|
+
export function currentGaIds(): GaIds | null {
|
|
38
|
+
try {
|
|
39
|
+
return typeof document === "undefined" ? null : readGaIds(document.cookie);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
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
|
+
}
|
|
@@ -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
|
+
}
|