@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
|
@@ -12,7 +12,10 @@ import React from "react";
|
|
|
12
12
|
import { useProduct } from "../hooks/useProduct";
|
|
13
13
|
import type { ProductOffer, OfferFeature } from "../types";
|
|
14
14
|
import { cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
|
|
15
|
+
import { formatFeatureLabel } from "../utils/format";
|
|
16
|
+
import { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
|
|
15
17
|
import { FeatureIcon } from "./FeatureIcon";
|
|
18
|
+
import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers, type IntervalEntry } from "./IntervalToggle";
|
|
16
19
|
|
|
17
20
|
export interface PricingDetailPageProps {
|
|
18
21
|
/** Product slug (e.g., "raise-simpli") */
|
|
@@ -23,14 +26,23 @@ export interface PricingDetailPageProps {
|
|
|
23
26
|
|
|
24
27
|
/** Whether to show the feature comparison table (defaults to true) */
|
|
25
28
|
showComparison?: boolean;
|
|
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;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
export function PricingDetailPage({
|
|
29
36
|
productId,
|
|
30
37
|
onSelectOffer,
|
|
31
38
|
showComparison = true,
|
|
39
|
+
currentPlan,
|
|
32
40
|
}: PricingDetailPageProps) {
|
|
33
41
|
const { product, loading, error } = useProduct(productId);
|
|
42
|
+
const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
|
|
43
|
+
product?.offers ?? [],
|
|
44
|
+
resolveCurrentOfferId(product?.offers ?? [], currentPlan),
|
|
45
|
+
);
|
|
34
46
|
|
|
35
47
|
if (loading) {
|
|
36
48
|
return (
|
|
@@ -56,10 +68,16 @@ export function PricingDetailPage({
|
|
|
56
68
|
);
|
|
57
69
|
}
|
|
58
70
|
|
|
71
|
+
// One offer per tier for the chosen interval: cards, "Everything in X" and
|
|
72
|
+
// the comparison columns all read this, never the raw list (which would
|
|
73
|
+
// add a "Lite Annual" column beside "Lite").
|
|
74
|
+
const visibleOffers = entries.map((e) => e.offer);
|
|
75
|
+
const currentOfferId = resolveCurrentOfferId(product.offers, currentPlan);
|
|
76
|
+
|
|
59
77
|
// Extract all unique feature keys across all offers (preserving order)
|
|
60
78
|
const allFeatureKeys: string[] = [];
|
|
61
79
|
const featureNameMap: Record<string, string> = {};
|
|
62
|
-
for (const offer of
|
|
80
|
+
for (const offer of visibleOffers) {
|
|
63
81
|
for (const feature of offer.features) {
|
|
64
82
|
if (!allFeatureKeys.includes(feature.key)) {
|
|
65
83
|
allFeatureKeys.push(feature.key);
|
|
@@ -79,20 +97,30 @@ export function PricingDetailPage({
|
|
|
79
97
|
</p>
|
|
80
98
|
</div>
|
|
81
99
|
|
|
100
|
+
{hasChoice && (
|
|
101
|
+
<div className="mb-10">
|
|
102
|
+
<IntervalToggle interval={interval} onChange={setInterval} />
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
|
|
82
106
|
<div className="flex flex-wrap justify-center gap-6 items-stretch mb-16">
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
107
|
+
{entries.map((entry) => {
|
|
108
|
+
const below = tierBelow(visibleOffers, entry.offer);
|
|
109
|
+
return (
|
|
110
|
+
<DetailCard
|
|
111
|
+
key={entry.offer.id}
|
|
112
|
+
entry={entry}
|
|
113
|
+
onSelect={onSelectOffer}
|
|
114
|
+
buildsOn={below ? entries.find((e) => e.offer.id === below.id)?.tier.name ?? below.name : null}
|
|
115
|
+
isCurrent={entry.offer.id === currentOfferId}
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
91
119
|
</div>
|
|
92
120
|
|
|
93
121
|
{showComparison && (
|
|
94
122
|
<ComparisonTable
|
|
95
|
-
|
|
123
|
+
entries={entries}
|
|
96
124
|
featureKeys={allFeatureKeys}
|
|
97
125
|
featureNameMap={featureNameMap}
|
|
98
126
|
/>
|
|
@@ -102,13 +130,16 @@ export function PricingDetailPage({
|
|
|
102
130
|
}
|
|
103
131
|
|
|
104
132
|
interface DetailCardProps {
|
|
105
|
-
|
|
133
|
+
entry: IntervalEntry;
|
|
106
134
|
onSelect?: (offer: ProductOffer) => void;
|
|
107
135
|
/** Name of the tier just below this one, when there is one. */
|
|
108
136
|
buildsOn?: string | null;
|
|
137
|
+
/** The viewer is on this offer: "Current plan", disabled. */
|
|
138
|
+
isCurrent?: boolean;
|
|
109
139
|
}
|
|
110
140
|
|
|
111
|
-
function DetailCard({
|
|
141
|
+
function DetailCard({ entry, onSelect, buildsOn, isCurrent = false }: DetailCardProps) {
|
|
142
|
+
const { offer, tier } = entry;
|
|
112
143
|
const isFeatured = offer.isFeatured;
|
|
113
144
|
|
|
114
145
|
return (
|
|
@@ -128,21 +159,15 @@ function DetailCard({ offer, onSelect, buildsOn }: DetailCardProps) {
|
|
|
128
159
|
</span>
|
|
129
160
|
)}
|
|
130
161
|
|
|
131
|
-
<h2 className="text-xl font-bold text-gray-900 mb-4">{
|
|
162
|
+
<h2 className="text-xl font-bold text-gray-900 mb-4">{tier.name}</h2>
|
|
132
163
|
|
|
133
164
|
<div className="mb-2">
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<span aria-hidden="true" className="text-4xl font-extrabold text-gray-900">
|
|
141
|
-
${Math.floor(parseFloat(offer.unitPrice) || 0)}
|
|
142
|
-
</span>
|
|
143
|
-
<span aria-hidden="true" className="text-base text-gray-500 ml-1"> {offer.intervalDisplay}</span>
|
|
144
|
-
</span>
|
|
145
|
-
)}
|
|
165
|
+
<OfferPrice
|
|
166
|
+
offer={offer}
|
|
167
|
+
amountClassName="text-4xl font-extrabold text-gray-900"
|
|
168
|
+
intervalClassName="text-base text-gray-500"
|
|
169
|
+
/>
|
|
170
|
+
<AnnualNote entry={entry} className="mt-1" />
|
|
146
171
|
</div>
|
|
147
172
|
|
|
148
173
|
{offer.trialDays > 0 && (
|
|
@@ -164,40 +189,43 @@ function DetailCard({ offer, onSelect, buildsOn }: DetailCardProps) {
|
|
|
164
189
|
className="flex items-center gap-2.5 py-2.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
|
|
165
190
|
>
|
|
166
191
|
<FeatureIcon value={feature.value} />
|
|
167
|
-
<span>{feature
|
|
168
|
-
{typeof feature.value
|
|
169
|
-
<span className="ml-auto text-gray-900 font-medium">
|
|
170
|
-
{feature.value === -1 ? "Unlimited" : String(feature.value)}
|
|
171
|
-
</span>
|
|
192
|
+
<span>{formatFeatureLabel(feature)}</span>
|
|
193
|
+
{typeof feature.value === "string" && (
|
|
194
|
+
<span className="ml-auto text-gray-900 font-medium">{feature.value}</span>
|
|
172
195
|
)}
|
|
173
196
|
</li>
|
|
174
197
|
))}
|
|
175
198
|
</ul>
|
|
176
199
|
|
|
177
200
|
<button
|
|
178
|
-
onClick={() => onSelect?.(offer)}
|
|
201
|
+
onClick={() => !isCurrent && onSelect?.(offer)}
|
|
179
202
|
data-testid={`detail-cta-${offer.slug}`}
|
|
203
|
+
disabled={isCurrent}
|
|
204
|
+
aria-disabled={isCurrent || undefined}
|
|
180
205
|
className={`
|
|
181
|
-
w-full py-3.5 px-6 text-base font-bold rounded-xl
|
|
182
|
-
${
|
|
183
|
-
? "bg-
|
|
184
|
-
:
|
|
206
|
+
w-full py-3.5 px-6 text-base font-bold rounded-xl transition-colors
|
|
207
|
+
${isCurrent
|
|
208
|
+
? "bg-gray-100 text-gray-500 cursor-not-allowed"
|
|
209
|
+
: isFeatured
|
|
210
|
+
? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
|
|
211
|
+
: "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
|
|
185
212
|
}
|
|
186
213
|
`}
|
|
187
214
|
>
|
|
188
|
-
{offer.ctaText}
|
|
215
|
+
{isCurrent ? "Current plan" : offer.ctaText}
|
|
189
216
|
</button>
|
|
190
217
|
</div>
|
|
191
218
|
);
|
|
192
219
|
}
|
|
193
220
|
|
|
194
221
|
interface ComparisonTableProps {
|
|
195
|
-
|
|
222
|
+
entries: IntervalEntry[];
|
|
196
223
|
featureKeys: string[];
|
|
197
224
|
featureNameMap: Record<string, string>;
|
|
198
225
|
}
|
|
199
226
|
|
|
200
|
-
function ComparisonTable({
|
|
227
|
+
function ComparisonTable({ entries, featureKeys, featureNameMap }: ComparisonTableProps) {
|
|
228
|
+
const offers = entries.map((e) => e.offer);
|
|
201
229
|
// offer slug -> feature key -> feature, with cheaper tiers' capabilities
|
|
202
230
|
// carried upward so a paid plan never shows a dash where free shows a tick.
|
|
203
231
|
const featureLookup = cumulativeFeatureLookup(offers);
|
|
@@ -210,14 +238,14 @@ function ComparisonTable({ offers, featureKeys, featureNameMap }: ComparisonTabl
|
|
|
210
238
|
<th scope="col" className="text-left py-4 px-4 text-sm font-semibold text-gray-500 uppercase tracking-wider">
|
|
211
239
|
Feature
|
|
212
240
|
</th>
|
|
213
|
-
{
|
|
241
|
+
{entries.map(({ offer, tier }) => (
|
|
214
242
|
<th
|
|
215
243
|
key={offer.slug}
|
|
216
244
|
scope="col"
|
|
217
|
-
aria-label={
|
|
245
|
+
aria-label={tier.name}
|
|
218
246
|
className="text-center py-4 px-4 text-sm font-semibold text-gray-900"
|
|
219
247
|
>
|
|
220
|
-
<span aria-hidden="true">{
|
|
248
|
+
<span aria-hidden="true">{tier.name}</span>
|
|
221
249
|
</th>
|
|
222
250
|
))}
|
|
223
251
|
</tr>
|
|
@@ -218,9 +218,9 @@ describe("PricingPage", () => {
|
|
|
218
218
|
|
|
219
219
|
await waitFor(() => {
|
|
220
220
|
// Each offer has features; "Team seats" appears in all 3 cards
|
|
221
|
-
expect(screen.getAllByText(
|
|
221
|
+
expect(screen.getAllByText(/team seats$/i).length).toBe(3);
|
|
222
222
|
});
|
|
223
|
-
expect(screen.getAllByText(
|
|
223
|
+
expect(screen.getAllByText(/projects$/i).length).toBe(3);
|
|
224
224
|
});
|
|
225
225
|
|
|
226
226
|
it("shows trial badge when offer has trial days", async () => {
|
|
@@ -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
|
-
{
|
|
82
|
+
{entries.map((entry) => (
|
|
64
83
|
<PricingCard
|
|
65
|
-
key={offer.id}
|
|
66
|
-
|
|
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
|
-
|
|
96
|
+
entry: IntervalEntry;
|
|
77
97
|
onSelect?: (offer: ProductOffer) => void;
|
|
98
|
+
isCurrent?: boolean;
|
|
78
99
|
}
|
|
79
100
|
|
|
80
|
-
function PricingCard({
|
|
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">{
|
|
123
|
+
<h2 className="text-xl font-bold text-gray-900 mb-4">{tier.name}</h2>
|
|
102
124
|
|
|
103
125
|
<div className="mb-2">
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
142
|
-
${
|
|
143
|
-
? "bg-
|
|
144
|
-
:
|
|
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(
|
|
223
|
-
expect(screen.getAllByText(
|
|
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(
|
|
244
|
-
expect(screen.getAllByText(
|
|
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
|
-
{
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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({
|
|
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">{
|
|
127
|
+
<h3 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h3>
|
|
100
128
|
|
|
101
129
|
<div className="mb-2">
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
<
|
|
136
|
-
|
|
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
|
|
154
|
-
${
|
|
155
|
-
? "bg-
|
|
156
|
-
:
|
|
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
|
);
|
|
@@ -408,7 +408,9 @@ describe("SubscriptionManager", () => {
|
|
|
408
408
|
expect(screen.getByText(/manage billing/i)).toBeDefined();
|
|
409
409
|
});
|
|
410
410
|
|
|
411
|
-
|
|
411
|
+
// A signed-in user with no subscription row is on the free plan — the 404
|
|
412
|
+
// path only happens once the session is accepted (crochet_patterns-34n7).
|
|
413
|
+
it("shows the free plan as theirs when no subscription exists", async () => {
|
|
412
414
|
const fetcher = mockFetchRouter({
|
|
413
415
|
"/billing/subscription/current/": null, // 404 - no subscription
|
|
414
416
|
"/billing/products/": mockProduct,
|
|
@@ -425,8 +427,9 @@ describe("SubscriptionManager", () => {
|
|
|
425
427
|
);
|
|
426
428
|
|
|
427
429
|
await waitFor(() => {
|
|
428
|
-
expect(screen.getByText(/
|
|
430
|
+
expect(screen.getByText(/you're on the starter plan/i)).toBeDefined();
|
|
429
431
|
});
|
|
432
|
+
expect(screen.queryByText(/no active subscription/i)).toBeNull();
|
|
430
433
|
});
|
|
431
434
|
|
|
432
435
|
it("shows subscribe options when no subscription", async () => {
|
|
@@ -591,7 +594,8 @@ describe("SubscriptionManager", () => {
|
|
|
591
594
|
const trialNodes = screen.getAllByTestId("trial-copy");
|
|
592
595
|
expect(trialNodes.length).toBe(1);
|
|
593
596
|
expect(trialNodes[0].textContent).toMatch(/14-day free trial/i);
|
|
594
|
-
|
|
597
|
+
// Checkout takes a card up front, so never promise otherwise (crochet_patterns-34n7).
|
|
598
|
+
expect(trialNodes[0].textContent).not.toMatch(/no card/i);
|
|
595
599
|
});
|
|
596
600
|
|
|
597
601
|
it("renders 'Unlimited X' for features with limit === -1 (o7u)", async () => {
|
|
@@ -631,8 +635,10 @@ describe("SubscriptionManager", () => {
|
|
|
631
635
|
|
|
632
636
|
it("free-offer CTA calls subscribe-free, not Stripe checkout (6vk)", async () => {
|
|
633
637
|
const onPlanChange = vi.fn();
|
|
638
|
+
// On Pro, so the Free card is a downgrade and clickable (with no
|
|
639
|
+
// subscription Free is the current plan and its CTA is disabled).
|
|
634
640
|
const fetcher = mockFetchRouter({
|
|
635
|
-
"/billing/subscription/current/":
|
|
641
|
+
"/billing/subscription/current/": mockSubscriptionActive,
|
|
636
642
|
"/billing/products/": mockProduct,
|
|
637
643
|
});
|
|
638
644
|
const Wrapper = createWrapper(fetcher);
|
|
@@ -785,11 +791,12 @@ describe("SubscriptionManager", () => {
|
|
|
785
791
|
};
|
|
786
792
|
|
|
787
793
|
const cases: Case[] = [
|
|
788
|
-
// No subscription (
|
|
789
|
-
|
|
794
|
+
// No subscription row (signed in, never enrolled): they are on the free
|
|
795
|
+
// plan, so it reads Current plan and is disabled (crochet_patterns-34n7).
|
|
796
|
+
{ label: "no-sub · Starter card → 'Current plan' disabled badge",
|
|
790
797
|
subscription: null, cardSlug: "starter-monthly",
|
|
791
|
-
expectedCta: /
|
|
792
|
-
shouldShowCurrentBadge:
|
|
798
|
+
expectedCta: /current plan/i, shouldBeDisabled: true,
|
|
799
|
+
shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
|
|
793
800
|
{ label: "no-sub · Pro card → 'Start Trial' (upgrade)",
|
|
794
801
|
subscription: null, cardSlug: "pro-monthly",
|
|
795
802
|
expectedCta: /start trial/i, shouldBeDisabled: false,
|
|
@@ -1274,9 +1281,9 @@ describe("SubscriptionManager", () => {
|
|
|
1274
1281
|
</Wrapper>
|
|
1275
1282
|
);
|
|
1276
1283
|
|
|
1277
|
-
// No crash; falls through to the
|
|
1284
|
+
// No crash; falls through to the free-plan copy.
|
|
1278
1285
|
await waitFor(() =>
|
|
1279
|
-
expect(screen.getByText(
|
|
1286
|
+
expect(screen.getByText(/you're on the starter plan/i)).toBeDefined()
|
|
1280
1287
|
);
|
|
1281
1288
|
});
|
|
1282
1289
|
|