@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
|
@@ -21,6 +21,8 @@ import { useUsage } from "../hooks/useUsage";
|
|
|
21
21
|
import { useSuccessSync } from "../hooks/useSuccessSync";
|
|
22
22
|
import type { OfferFeature, ProductOffer } from "../types";
|
|
23
23
|
import { CurrentPlanCard } from "./CurrentPlanCard";
|
|
24
|
+
import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers } from "./IntervalToggle";
|
|
25
|
+
import { formatFeatureLabel } from "../utils/format";
|
|
24
26
|
|
|
25
27
|
export interface SubscriptionManagerProps {
|
|
26
28
|
/** Product slug (e.g., "raise-simpli") */
|
|
@@ -41,16 +43,8 @@ export interface SubscriptionManagerProps {
|
|
|
41
43
|
* "Unlimited X" (-1) / "Up to N X" / "X" (no limit attribute).
|
|
42
44
|
* startsim-o7u — kills the blank gap between price and CTA on plan cards. */
|
|
43
45
|
function FeatureRow({ feature }: { feature: OfferFeature }) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
let label = feature.name;
|
|
48
|
-
if (typeof v === "number" || typeof feature.limit === "number") {
|
|
49
|
-
const limit = typeof feature.limit === "number" ? feature.limit
|
|
50
|
-
: typeof v === "number" ? v : null;
|
|
51
|
-
if (limit === -1) label = `Unlimited ${feature.name.toLowerCase()}`;
|
|
52
|
-
else if (limit !== null && limit > 0) label = `Up to ${limit} ${feature.name.toLowerCase()}`;
|
|
53
|
-
}
|
|
46
|
+
if (feature.value === false) return null;
|
|
47
|
+
const label = formatFeatureLabel(feature);
|
|
54
48
|
|
|
55
49
|
return (
|
|
56
50
|
<li className="flex items-start gap-2 text-sm text-gray-700">
|
|
@@ -73,12 +67,14 @@ function PlanFeatureList({ features }: { features: OfferFeature[] }) {
|
|
|
73
67
|
}
|
|
74
68
|
|
|
75
69
|
/** Subtle trial copy below the price block on offers that ship a trial.
|
|
76
|
-
* startsim-pih — so users know "Start Free Trial" actually IS a free trial.
|
|
70
|
+
* startsim-pih — so users know "Start Free Trial" actually IS a free trial.
|
|
71
|
+
* No "no card required": checkout collects a card up front and the offer
|
|
72
|
+
* carries nothing that says otherwise (crochet_patterns-34n7). */
|
|
77
73
|
function TrialCopy({ trialDays }: { trialDays: number }) {
|
|
78
74
|
if (!trialDays || trialDays <= 0) return null;
|
|
79
75
|
return (
|
|
80
76
|
<p data-testid="trial-copy" className="-mt-1 mb-3 text-xs text-gray-500">
|
|
81
|
-
{trialDays}-day free trial
|
|
77
|
+
{trialDays}-day free trial
|
|
82
78
|
</p>
|
|
83
79
|
);
|
|
84
80
|
}
|
|
@@ -178,6 +174,10 @@ export function SubscriptionManager({
|
|
|
178
174
|
const { subscribeFree } = useCheckout();
|
|
179
175
|
const { usage } = useUsage(productId);
|
|
180
176
|
const { sync: successSync } = useSuccessSync();
|
|
177
|
+
const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
|
|
178
|
+
product?.offers ?? [],
|
|
179
|
+
subscription?.offer?.id ?? null,
|
|
180
|
+
);
|
|
181
181
|
|
|
182
182
|
// startsim-ngw — when the user lands on ?status=success (just back from
|
|
183
183
|
// Stripe checkout), POST /billing/success-sync/ ONCE to populate the cache
|
|
@@ -233,22 +233,31 @@ export function SubscriptionManager({
|
|
|
233
233
|
// BillingApiClient, but guard here too so CurrentPlanCard can never deref
|
|
234
234
|
// `subscription.offer.*` on undefined and white-screen the page.
|
|
235
235
|
const usableSubscription = subscription?.offer ? subscription : null;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
236
|
+
// No subscription row once the fetch has settled (404 → null; a signed-out
|
|
237
|
+
// viewer gets a 401 and the error branch above) means the free plan:
|
|
238
|
+
// that card reads "Current plan" and is disabled (crochet_patterns-34n7).
|
|
239
|
+
const freeOffer = product?.offers.find((o) => o.isFree) ?? null;
|
|
240
|
+
const onFreeByDefault = !usableSubscription && !subLoading && !subError && freeOffer !== null;
|
|
241
|
+
const currentOffer = usableSubscription?.offer ?? (onFreeByDefault ? freeOffer : null);
|
|
242
|
+
const tierKeyOf = (offerId: string) =>
|
|
243
|
+
entries.find(({ tier }) => tier.monthly?.id === offerId || tier.annual?.id === offerId)?.tier.key ?? null;
|
|
244
|
+
// Show every tier in the Change Plan grid (not just non-current ones) so
|
|
245
|
+
// users can compare and see what they have; `entries` is one offer per tier
|
|
246
|
+
// for the chosen interval. The card matching their current plan gets a
|
|
247
|
+
// "Current plan" badge + disabled CTA. startsim-1f1, crochet_patterns-34n7.
|
|
242
248
|
|
|
243
249
|
/** Per-card direction relative to the current subscription:
|
|
244
250
|
* - "current" → user is on this exact offer → "Current Plan" badge, no action
|
|
245
251
|
* - "upgrade" → card is a higher tier (paid vs current free, or higher sortOrder)
|
|
246
252
|
* - "downgrade" → card is a lower tier (free when current is paid, or lower sortOrder)
|
|
247
253
|
* startsim-21f. */
|
|
248
|
-
type Direction = "current" | "upgrade" | "downgrade";
|
|
254
|
+
type Direction = "current" | "upgrade" | "downgrade" | "switch";
|
|
249
255
|
function directionFor(cardOffer: ProductOffer): Direction {
|
|
250
|
-
if (!currentOffer) return "upgrade"; //
|
|
256
|
+
if (!currentOffer) return "upgrade"; // plan still unknown → every card is an upgrade
|
|
251
257
|
if (cardOffer.id === currentOffer.id) return "current";
|
|
258
|
+
// Same plan, other billing interval (Lite ↔ Lite Annual).
|
|
259
|
+
const tierKey = tierKeyOf(cardOffer.id);
|
|
260
|
+
if (tierKey !== null && tierKey === tierKeyOf(currentOffer.id)) return "switch";
|
|
252
261
|
// Free vs Paid: paid is always an upgrade, free is always a downgrade.
|
|
253
262
|
if (cardOffer.isFree && !currentOffer.isFree) return "downgrade";
|
|
254
263
|
if (!cardOffer.isFree && currentOffer.isFree) return "upgrade";
|
|
@@ -260,7 +269,8 @@ export function SubscriptionManager({
|
|
|
260
269
|
* `offer.ctaText` (e.g. "Start Free Trial") is for UPGRADE only — using it
|
|
261
270
|
* on a Free card when a Pro user is looking is the bug startsim-21f closes. */
|
|
262
271
|
function ctaLabelFor(cardOffer: ProductOffer, direction: Direction): string {
|
|
263
|
-
if (direction === "current") return "Current
|
|
272
|
+
if (direction === "current") return "Current plan";
|
|
273
|
+
if (direction === "switch") return cardOffer.intervalUnit === "year" ? "Switch to annual" : "Switch to monthly";
|
|
264
274
|
if (direction === "downgrade") return `Downgrade to ${cardOffer.name}`;
|
|
265
275
|
return cardOffer.ctaText || `Upgrade to ${cardOffer.name}`;
|
|
266
276
|
}
|
|
@@ -379,6 +389,11 @@ export function SubscriptionManager({
|
|
|
379
389
|
}
|
|
380
390
|
/>
|
|
381
391
|
</>
|
|
392
|
+
) : onFreeByDefault && freeOffer ? (
|
|
393
|
+
<div data-testid="free-by-default" className="text-center py-8">
|
|
394
|
+
<p className="text-lg text-gray-600">You're on the {freeOffer.name} plan</p>
|
|
395
|
+
<p className="text-sm text-gray-400 mt-1">Choose a plan below to upgrade.</p>
|
|
396
|
+
</div>
|
|
382
397
|
) : (
|
|
383
398
|
<div className="text-center py-8">
|
|
384
399
|
<p className="text-lg text-gray-600">No active subscription</p>
|
|
@@ -419,16 +434,18 @@ export function SubscriptionManager({
|
|
|
419
434
|
|
|
420
435
|
{/* Only render the Change Plan section when there are at least 2 offers
|
|
421
436
|
to show. Single-tier apps (just Free) skip it entirely — startsim-ft2. */}
|
|
422
|
-
{!productLoading && !productError &&
|
|
437
|
+
{!productLoading && !productError && entries.length >= 2 && (
|
|
423
438
|
<div data-testid="available-plans" className="space-y-4">
|
|
424
439
|
<h3 className="text-lg font-semibold text-gray-900">
|
|
425
440
|
{subscription ? "Change Plan" : "Available Plans"}
|
|
426
441
|
</h3>
|
|
442
|
+
{hasChoice && <IntervalToggle interval={interval} onChange={setInterval} />}
|
|
427
443
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
428
|
-
{
|
|
444
|
+
{entries.map((entry) => {
|
|
445
|
+
const { offer, tier } = entry;
|
|
429
446
|
const direction = directionFor(offer);
|
|
430
447
|
const isCurrent = direction === "current";
|
|
431
|
-
const isDowngrade = direction === "downgrade";
|
|
448
|
+
const isDowngrade = direction === "downgrade" || direction === "switch";
|
|
432
449
|
const ctaLabel = ctaLabelFor(offer, direction);
|
|
433
450
|
|
|
434
451
|
// Border: current = solid ring; featured upgrade = blue; else gray.
|
|
@@ -459,25 +476,17 @@ export function SubscriptionManager({
|
|
|
459
476
|
data-testid={`current-plan-badge-${offer.slug}`}
|
|
460
477
|
className="absolute -top-2.5 right-4 inline-block bg-primary text-primary-foreground text-xs font-bold uppercase tracking-wide px-2.5 py-0.5 rounded-full"
|
|
461
478
|
>
|
|
462
|
-
Current
|
|
479
|
+
Current plan
|
|
463
480
|
</span>
|
|
464
481
|
)}
|
|
465
|
-
<h4 className="text-lg font-bold text-gray-900 mb-2">{
|
|
482
|
+
<h4 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h4>
|
|
466
483
|
<div className="mb-3">
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
<span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">
|
|
474
|
-
${Math.floor(parseFloat(offer.unitPrice) || 0)}
|
|
475
|
-
</span>
|
|
476
|
-
<span aria-hidden="true" className="text-sm text-gray-500 ml-1">
|
|
477
|
-
{offer.intervalDisplay}
|
|
478
|
-
</span>
|
|
479
|
-
</span>
|
|
480
|
-
)}
|
|
484
|
+
<OfferPrice
|
|
485
|
+
offer={offer}
|
|
486
|
+
amountClassName="text-2xl font-extrabold text-gray-900"
|
|
487
|
+
intervalClassName="text-sm text-gray-500"
|
|
488
|
+
/>
|
|
489
|
+
<AnnualNote entry={entry} className="mt-1" />
|
|
481
490
|
</div>
|
|
482
491
|
{/* Trial copy is for prospective trialers only — once the
|
|
483
492
|
user is on this offer or moving downward, drop the
|
|
@@ -13,12 +13,19 @@ import React, { useEffect, useRef, useCallback } from "react";
|
|
|
13
13
|
import { useProduct } from "../hooks/useProduct";
|
|
14
14
|
import type { ProductOffer } from "../types";
|
|
15
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 } from "./IntervalToggle";
|
|
16
19
|
|
|
17
20
|
export interface UpgradeModalProps {
|
|
18
21
|
productId: string;
|
|
19
22
|
open: boolean;
|
|
20
23
|
onClose: () => void;
|
|
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
31
|
export function UpgradeModal({
|
|
@@ -26,8 +33,13 @@ export function UpgradeModal({
|
|
|
26
33
|
open,
|
|
27
34
|
onClose,
|
|
28
35
|
onSelectOffer,
|
|
36
|
+
currentPlan,
|
|
29
37
|
}: UpgradeModalProps) {
|
|
30
38
|
const { product, loading, error } = useProduct(productId);
|
|
39
|
+
const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
|
|
40
|
+
product?.offers ?? [],
|
|
41
|
+
resolveCurrentOfferId(product?.offers ?? [], currentPlan),
|
|
42
|
+
);
|
|
31
43
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
32
44
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
33
45
|
|
|
@@ -120,10 +132,18 @@ export function UpgradeModal({
|
|
|
120
132
|
</div>
|
|
121
133
|
)}
|
|
122
134
|
|
|
135
|
+
{product && hasChoice && (
|
|
136
|
+
<div className="mb-6">
|
|
137
|
+
<IntervalToggle interval={interval} onChange={setInterval} />
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
|
|
123
141
|
{product && (
|
|
124
142
|
<div data-testid="upgrade-offers" className="flex flex-wrap justify-center gap-5">
|
|
125
|
-
{
|
|
143
|
+
{entries.map((entry) => {
|
|
144
|
+
const { offer, tier } = entry;
|
|
126
145
|
const isFeatured = offer.isFeatured;
|
|
146
|
+
const isCurrent = offer.id === resolveCurrentOfferId(product.offers, currentPlan);
|
|
127
147
|
return (
|
|
128
148
|
<div
|
|
129
149
|
key={offer.id}
|
|
@@ -136,21 +156,15 @@ export function UpgradeModal({
|
|
|
136
156
|
}
|
|
137
157
|
`}
|
|
138
158
|
>
|
|
139
|
-
<h3 className="text-lg font-bold text-gray-900 mb-2">{
|
|
159
|
+
<h3 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h3>
|
|
140
160
|
|
|
141
161
|
<div className="mb-2">
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
<span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">
|
|
149
|
-
${Math.floor(parseFloat(offer.unitPrice) || 0)}
|
|
150
|
-
</span>
|
|
151
|
-
<span aria-hidden="true" className="text-sm text-gray-500"> {offer.intervalDisplay}</span>
|
|
152
|
-
</span>
|
|
153
|
-
)}
|
|
162
|
+
<OfferPrice
|
|
163
|
+
offer={offer}
|
|
164
|
+
amountClassName="text-2xl font-extrabold text-gray-900"
|
|
165
|
+
intervalClassName="text-sm text-gray-500"
|
|
166
|
+
/>
|
|
167
|
+
<AnnualNote entry={entry} className="mt-1" />
|
|
154
168
|
</div>
|
|
155
169
|
|
|
156
170
|
{offer.trialDays > 0 && (
|
|
@@ -164,24 +178,28 @@ export function UpgradeModal({
|
|
|
164
178
|
{offer.features.map((f) => (
|
|
165
179
|
<li key={f.key} className="flex items-center gap-1.5 py-1 text-sm text-gray-600">
|
|
166
180
|
<FeatureIcon value={f.value} size="w-3.5 h-3.5" />
|
|
167
|
-
{f
|
|
181
|
+
<span>{formatFeatureLabel(f)}</span>
|
|
168
182
|
</li>
|
|
169
183
|
))}
|
|
170
184
|
</ul>
|
|
171
185
|
)}
|
|
172
186
|
|
|
173
187
|
<button
|
|
174
|
-
onClick={() => onSelectOffer?.(offer)}
|
|
188
|
+
onClick={() => !isCurrent && onSelectOffer?.(offer)}
|
|
175
189
|
data-testid={`upgrade-cta-${offer.slug}`}
|
|
190
|
+
disabled={isCurrent}
|
|
191
|
+
aria-disabled={isCurrent || undefined}
|
|
176
192
|
className={`
|
|
177
|
-
w-full py-2.5 px-4 text-sm font-bold rounded-lg
|
|
178
|
-
${
|
|
179
|
-
? "bg-
|
|
180
|
-
:
|
|
193
|
+
w-full py-2.5 px-4 text-sm font-bold rounded-lg transition-colors
|
|
194
|
+
${isCurrent
|
|
195
|
+
? "bg-gray-100 text-gray-500 cursor-not-allowed"
|
|
196
|
+
: isFeatured
|
|
197
|
+
? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
|
|
198
|
+
: "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
|
|
181
199
|
}
|
|
182
200
|
`}
|
|
183
201
|
>
|
|
184
|
-
{offer.ctaText}
|
|
202
|
+
{isCurrent ? "Current plan" : offer.ctaText}
|
|
185
203
|
</button>
|
|
186
204
|
</div>
|
|
187
205
|
);
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live crochets.site catalogue (GET /billing/products/crochet-patterns/,
|
|
3
|
+
* 2026-10-16), camelised the way the proxy hands it to the components.
|
|
4
|
+
* `tier` is not served yet, so the base fixture omits it on purpose: the
|
|
5
|
+
* picker has to pair Lite with Lite Annual without it.
|
|
6
|
+
*/
|
|
7
|
+
import type { BillingProduct, ProductOffer, SubscriptionInfo } from "../../types";
|
|
8
|
+
|
|
9
|
+
const base = {
|
|
10
|
+
currency: "USD",
|
|
11
|
+
pricingModel: "flat" as const,
|
|
12
|
+
intervalCount: 1,
|
|
13
|
+
isRecurring: true,
|
|
14
|
+
isActive: true,
|
|
15
|
+
isFeatured: false,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const liteFeatures = [
|
|
19
|
+
{ key: "ai_designs", name: "AI pattern designs", value: true },
|
|
20
|
+
{ key: "ai_designs_per_month", name: "AI designs per month", value: 3 },
|
|
21
|
+
{ key: "ai_refine", name: "AI chat to refine any pattern", value: true },
|
|
22
|
+
{ key: "pdf_export", name: "Print-ready PDF export", value: true },
|
|
23
|
+
{ key: "save_patterns", name: "Save patterns to your library", value: true },
|
|
24
|
+
{ key: "track_progress", name: "Track your progress", value: true },
|
|
25
|
+
{ key: "patterns_per_month", name: "Saved patterns per month", value: -1 },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const proFeatures = [
|
|
29
|
+
{ key: "ai_designs", name: "AI pattern designs, made with our best model and checked by a design review", value: true },
|
|
30
|
+
{ key: "patterns_per_month", name: "AI designs per month", value: -1 },
|
|
31
|
+
{ key: "ai_refine", name: "AI chat to refine any pattern", value: true },
|
|
32
|
+
{ key: "pdf_export", name: "Print-ready PDF export", value: true },
|
|
33
|
+
{ key: "custom_sizing", name: "Custom sizing", value: true },
|
|
34
|
+
{ key: "trial_note", name: "7-day free trial with AI designs included", value: true },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const crochetFree: ProductOffer = {
|
|
38
|
+
...base,
|
|
39
|
+
id: "free-id",
|
|
40
|
+
name: "Free",
|
|
41
|
+
slug: "crochet-free",
|
|
42
|
+
unitPrice: "0.00",
|
|
43
|
+
intervalUnit: "month",
|
|
44
|
+
intervalDisplay: "per month",
|
|
45
|
+
isFree: true,
|
|
46
|
+
features: [
|
|
47
|
+
{ key: "ai_designs", name: "AI pattern designs", value: false },
|
|
48
|
+
{ key: "browse_patterns", name: "Browse and buy patterns", value: true },
|
|
49
|
+
{ key: "save_patterns", name: "Save patterns to your library", value: true },
|
|
50
|
+
{ key: "track_progress", name: "Track your progress", value: true },
|
|
51
|
+
{ key: "pdf_export", name: "Print-ready PDF export", limit: null, value: true },
|
|
52
|
+
],
|
|
53
|
+
trialDays: 0,
|
|
54
|
+
sortOrder: 0,
|
|
55
|
+
ctaText: "Get Started",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const crochetLite: ProductOffer = {
|
|
59
|
+
...base,
|
|
60
|
+
id: "lite-id",
|
|
61
|
+
name: "Lite",
|
|
62
|
+
slug: "crochet-lite",
|
|
63
|
+
unitPrice: "2.99",
|
|
64
|
+
intervalUnit: "month",
|
|
65
|
+
intervalDisplay: "per month",
|
|
66
|
+
isFree: false,
|
|
67
|
+
features: liteFeatures,
|
|
68
|
+
trialDays: 0,
|
|
69
|
+
sortOrder: 1,
|
|
70
|
+
ctaText: "Get Lite",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const crochetLiteAnnual: ProductOffer = {
|
|
74
|
+
...base,
|
|
75
|
+
id: "lite-annual-id",
|
|
76
|
+
name: "Lite Annual",
|
|
77
|
+
slug: "crochet-lite-annual",
|
|
78
|
+
unitPrice: "29.00",
|
|
79
|
+
intervalUnit: "year",
|
|
80
|
+
intervalDisplay: "per year",
|
|
81
|
+
isFree: false,
|
|
82
|
+
features: liteFeatures,
|
|
83
|
+
trialDays: 0,
|
|
84
|
+
sortOrder: 1,
|
|
85
|
+
ctaText: "Get Lite",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const crochetPro: ProductOffer = {
|
|
89
|
+
...base,
|
|
90
|
+
id: "pro-id",
|
|
91
|
+
name: "Pro",
|
|
92
|
+
slug: "crochet-pro",
|
|
93
|
+
unitPrice: "9.00",
|
|
94
|
+
intervalUnit: "month",
|
|
95
|
+
intervalDisplay: "per month",
|
|
96
|
+
isFree: false,
|
|
97
|
+
features: proFeatures,
|
|
98
|
+
trialDays: 7,
|
|
99
|
+
sortOrder: 2,
|
|
100
|
+
ctaText: "Start Free Trial",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const crochetProAnnual: ProductOffer = {
|
|
104
|
+
...base,
|
|
105
|
+
id: "pro-annual-id",
|
|
106
|
+
name: "Pro Annual",
|
|
107
|
+
slug: "crochet-pro-annual",
|
|
108
|
+
unitPrice: "89.00",
|
|
109
|
+
intervalUnit: "year",
|
|
110
|
+
intervalDisplay: "per year",
|
|
111
|
+
isFree: false,
|
|
112
|
+
features: proFeatures,
|
|
113
|
+
trialDays: 7,
|
|
114
|
+
sortOrder: 2,
|
|
115
|
+
ctaText: "Start Free Trial",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** What production serves today: no Pro Annual, no `tier`. */
|
|
119
|
+
export const crochetProduct: BillingProduct = {
|
|
120
|
+
id: "034a56c9",
|
|
121
|
+
slug: "crochet-patterns",
|
|
122
|
+
name: "Crochet Patterns",
|
|
123
|
+
description: "AI-powered crochet pattern generator with step-by-step instructions.",
|
|
124
|
+
offers: [crochetFree, crochetLite, crochetLiteAnnual, crochetPro],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** After the in-flight deploy: `tier` on every paid offer, plus Pro Annual. */
|
|
128
|
+
export const crochetProductWithTiers: BillingProduct = {
|
|
129
|
+
...crochetProduct,
|
|
130
|
+
offers: [
|
|
131
|
+
crochetFree,
|
|
132
|
+
{ ...crochetLite, tier: "lite" },
|
|
133
|
+
{ ...crochetLiteAnnual, tier: "lite" },
|
|
134
|
+
{ ...crochetPro, tier: "pro" },
|
|
135
|
+
{ ...crochetProAnnual, tier: "pro" },
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export function subscriptionTo(offer: ProductOffer, status: SubscriptionInfo["status"] = "active"): SubscriptionInfo {
|
|
140
|
+
return {
|
|
141
|
+
id: `sub-${offer.slug}`,
|
|
142
|
+
offer,
|
|
143
|
+
status,
|
|
144
|
+
currentPeriodStart: "2026-10-01T00:00:00Z",
|
|
145
|
+
currentPeriodEnd: "2026-11-01T00:00:00Z",
|
|
146
|
+
cancelAtPeriodEnd: false,
|
|
147
|
+
trialEnd: null,
|
|
148
|
+
viewerIsOwner: true,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A fetch that serves the product and the given subscription (null = 404). */
|
|
153
|
+
export function crochetFetcher(
|
|
154
|
+
product: BillingProduct,
|
|
155
|
+
subscription: SubscriptionInfo | null,
|
|
156
|
+
): typeof fetch {
|
|
157
|
+
return ((url: string) => {
|
|
158
|
+
if (url.includes("/billing/subscription/current/")) {
|
|
159
|
+
if (subscription === null) {
|
|
160
|
+
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({ detail: "none" }) });
|
|
161
|
+
}
|
|
162
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(subscription) });
|
|
163
|
+
}
|
|
164
|
+
if (url.includes("/billing/usage/")) {
|
|
165
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) });
|
|
166
|
+
}
|
|
167
|
+
if (url.includes("/billing/products/")) {
|
|
168
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(product) });
|
|
169
|
+
}
|
|
170
|
+
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
|
|
171
|
+
}) as unknown as typeof fetch;
|
|
172
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -57,5 +57,8 @@ export type {
|
|
|
57
57
|
export { BillingApiClient } from "./utils/api";
|
|
58
58
|
|
|
59
59
|
// Pure helpers
|
|
60
|
-
export { isPaidSubscription, getFeatureLimit, resolvePlanEntitlement, resolveUsageWindow } from "./utils/plan";
|
|
61
|
-
export type { PlanEntitlement, ResolvePlanEntitlementOptions, UsageWindow } from "./utils/plan";
|
|
60
|
+
export { isPaidSubscription, getFeatureLimit, resolvePlanEntitlement, resolveUsageWindow, resolveCurrentOfferId } from "./utils/plan";
|
|
61
|
+
export type { PlanEntitlement, ResolvePlanEntitlementOptions, UsageWindow, CurrentPlanState } from "./utils/plan";
|
|
62
|
+
export { formatPrice, formatFeatureLabel } from "./utils/format";
|
|
63
|
+
export { groupOffersByTier, offersForInterval, hasIntervalChoice, annualSavings, tierOffer } from "./utils/tiers";
|
|
64
|
+
export type { BillingInterval, OfferTier } from "./utils/tiers";
|
package/src/types/index.ts
CHANGED
|
@@ -23,6 +23,10 @@ export interface ProductOffer {
|
|
|
23
23
|
trialDays: number;
|
|
24
24
|
sortOrder: number;
|
|
25
25
|
ctaText: string;
|
|
26
|
+
/** Plan tier shared by an offer's monthly and annual prices ("lite",
|
|
27
|
+
* "pro"). Optional: older payloads omit it and the picker pairs by
|
|
28
|
+
* features / name instead. */
|
|
29
|
+
tier?: string | null;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export interface OfferFeature {
|
|
@@ -76,6 +80,8 @@ export interface SubscriptionInfo {
|
|
|
76
80
|
viewerIsOwner?: boolean;
|
|
77
81
|
/** Email of the team owner so non-owners can be told who to ask. */
|
|
78
82
|
ownerEmail?: string | null;
|
|
83
|
+
/** The server sends this subscription's GA4 conversion; the browser must not. */
|
|
84
|
+
conversionByServer?: boolean;
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
export interface PaymentMethod {
|
package/src/utils/api.test.ts
CHANGED
|
@@ -44,6 +44,19 @@ describe("BillingApiClient", () => {
|
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
describe("subscription conversion owner", () => {
|
|
48
|
+
it("keeps conversionByServer on both the rich and the flat cache shape", async () => {
|
|
49
|
+
const offer = { id: "o1", slug: "pro", name: "Pro" };
|
|
50
|
+
mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ id: "s1", offer, status: "trialing", conversionByServer: true }) });
|
|
51
|
+
expect((await createClient().getSubscription("p"))?.conversionByServer).toBe(true);
|
|
52
|
+
|
|
53
|
+
mockFetch
|
|
54
|
+
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ subscriptionId: "s1", status: "trialing", productSlug: "p", offerId: "o1", conversionByServer: true }) })
|
|
55
|
+
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ slug: "p", name: "P", offers: [offer] }) });
|
|
56
|
+
expect((await createClient().getSubscription("p"))?.conversionByServer).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
47
60
|
describe("createCheckout", () => {
|
|
48
61
|
it("sends POST without auth header (proxy handles auth server-side)", async () => {
|
|
49
62
|
mockFetch.mockResolvedValue({
|
|
@@ -71,6 +84,19 @@ describe("BillingApiClient", () => {
|
|
|
71
84
|
);
|
|
72
85
|
});
|
|
73
86
|
|
|
87
|
+
it("sends the buyer's GA ids so the server can send the conversion", async () => {
|
|
88
|
+
document.cookie = "_ga=GA1.1.1234567890.1760000000; path=/";
|
|
89
|
+
document.cookie = "_ga_K1M560RH1W=GS2.1.s1760600000$o3$g1$t1760600500; path=/";
|
|
90
|
+
mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ sessionId: "cs_1", url: "https://pay.com" }) });
|
|
91
|
+
|
|
92
|
+
await createClient().createCheckout({ offerId: "offer-1", successUrl: "https://ok.com", cancelUrl: "https://nope.com" });
|
|
93
|
+
|
|
94
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
95
|
+
expect(body.analytics).toEqual({ ga_client_id: "1234567890.1760000000", ga_session_id: "1760600000" });
|
|
96
|
+
document.cookie = "_ga=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
|
|
97
|
+
document.cookie = "_ga_K1M560RH1W=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
|
|
98
|
+
});
|
|
99
|
+
|
|
74
100
|
it("includes quantity in request body", async () => {
|
|
75
101
|
mockFetch.mockResolvedValue({
|
|
76
102
|
ok: true,
|
package/src/utils/api.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
UsageMap,
|
|
14
14
|
} from "../types";
|
|
15
15
|
import { billingErrorMessage } from "./error-message";
|
|
16
|
+
import { currentGaIds } from "./ga-ids";
|
|
16
17
|
|
|
17
18
|
export class BillingApiClient {
|
|
18
19
|
private config: BillingConfig;
|
|
@@ -49,6 +50,7 @@ export class BillingApiClient {
|
|
|
49
50
|
quantity?: number;
|
|
50
51
|
}): Promise<CheckoutResult> {
|
|
51
52
|
const url = `${this.config.apiBaseUrl}/billing/offer-checkout/`;
|
|
53
|
+
const ga = currentGaIds();
|
|
52
54
|
const resp = await this.fetcher(url, {
|
|
53
55
|
method: "POST",
|
|
54
56
|
headers: this.headers,
|
|
@@ -57,6 +59,8 @@ export class BillingApiClient {
|
|
|
57
59
|
success_url: params.successUrl,
|
|
58
60
|
cancel_url: params.cancelUrl,
|
|
59
61
|
quantity: params.quantity ?? 1,
|
|
62
|
+
// For the subscription's conversion, sent from the server.
|
|
63
|
+
...(ga && { analytics: ga }),
|
|
60
64
|
}),
|
|
61
65
|
});
|
|
62
66
|
if (!resp.ok) {
|
|
@@ -200,6 +204,7 @@ export class BillingApiClient {
|
|
|
200
204
|
currentPeriodEnd: toIso(r.currentPeriodEnd),
|
|
201
205
|
cancelAtPeriodEnd: Boolean(r.cancelAtPeriodEnd),
|
|
202
206
|
trialEnd: toIso(r.trialEnd),
|
|
207
|
+
conversionByServer: Boolean(r.conversionByServer),
|
|
203
208
|
};
|
|
204
209
|
} catch {
|
|
205
210
|
// Product fetch failed — degrade to "no subscription" instead of crashing.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Price and feature-label formatting shared by every plan card
|
|
3
|
+
* (crochet_patterns-34n7).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { formatPrice, formatFeatureLabel } from "./format";
|
|
7
|
+
|
|
8
|
+
describe("formatPrice", () => {
|
|
9
|
+
it("keeps the cents: 2.99 is $2.99, not $2", () => {
|
|
10
|
+
expect(formatPrice("2.99")).toBe("$2.99");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("drops .00 on whole amounts", () => {
|
|
14
|
+
expect(formatPrice("9.00")).toBe("$9");
|
|
15
|
+
expect(formatPrice("29.00")).toBe("$29");
|
|
16
|
+
expect(formatPrice("199.00")).toBe("$199");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("pads a single cent digit", () => {
|
|
20
|
+
expect(formatPrice("2.5")).toBe("$2.50");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("accepts numbers and other currencies", () => {
|
|
24
|
+
expect(formatPrice(2.42)).toBe("$2.42");
|
|
25
|
+
expect(formatPrice("10.00", "EUR")).toBe("€10");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("treats junk as zero rather than NaN", () => {
|
|
29
|
+
expect(formatPrice("")).toBe("$0");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("formatFeatureLabel", () => {
|
|
34
|
+
it("keeps acronyms capitalised in a capped allowance", () => {
|
|
35
|
+
expect(formatFeatureLabel({ key: "ai_designs_per_month", name: "AI designs per month", value: 3 }))
|
|
36
|
+
.toBe("Up to 3 AI designs per month");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not say per month for an unlimited allowance", () => {
|
|
40
|
+
expect(formatFeatureLabel({ key: "patterns_per_month", name: "Saved patterns per month", value: -1 }))
|
|
41
|
+
.toBe("Unlimited saved patterns");
|
|
42
|
+
expect(formatFeatureLabel({ key: "patterns_per_month", name: "AI designs per month", value: -1 }))
|
|
43
|
+
.toBe("Unlimited AI designs");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("prefers limit over value", () => {
|
|
47
|
+
expect(formatFeatureLabel({ key: "seats", name: "Team seats", value: 1, limit: 5 }))
|
|
48
|
+
.toBe("Up to 5 team seats");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("leaves capabilities and string values as their name", () => {
|
|
52
|
+
expect(formatFeatureLabel({ key: "pdf_export", name: "Print-ready PDF export", value: true }))
|
|
53
|
+
.toBe("Print-ready PDF export");
|
|
54
|
+
expect(formatFeatureLabel({ key: "projects", name: "Projects", value: "Unlimited" }))
|
|
55
|
+
.toBe("Projects");
|
|
56
|
+
});
|
|
57
|
+
});
|