@startsimpli/billing 0.4.23 → 0.4.25
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/PlanChange.crochet.test.tsx +274 -0
- package/src/components/PlanChangeConfirm.tsx +193 -0
- package/src/components/PlanPicker.crochet.test.tsx +322 -0
- package/src/components/PricingDetailPage.tsx +144 -47
- 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 +38 -19
- package/src/components/SubscriptionManager.tsx +168 -99
- package/src/components/UpgradeModal.tsx +89 -27
- package/src/components/__fixtures__/crochetProduct.ts +172 -0
- package/src/components/index.ts +2 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/usePlanChange.ts +144 -0
- package/src/hooks/useSuccessSync.ts +3 -3
- package/src/index.ts +23 -4
- package/src/types/index.ts +33 -0
- package/src/utils/api.ts +38 -1
- package/src/utils/format.test.ts +57 -0
- package/src/utils/format.ts +54 -0
- package/src/utils/plan.ts +122 -0
- package/src/utils/tiers.interval.test.ts +76 -0
- package/src/utils/tiers.ts +105 -0
|
@@ -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
|
);
|
|
@@ -376,12 +376,15 @@ describe("SubscriptionManager", () => {
|
|
|
376
376
|
expect(screen.getByTestId("available-plans")).toBeDefined();
|
|
377
377
|
});
|
|
378
378
|
|
|
379
|
-
// Click Enterprise upgrade option
|
|
379
|
+
// Click Enterprise upgrade option: already paying, so it asks first.
|
|
380
380
|
const enterpriseButton = screen.getByTestId("upgrade-cta-enterprise-monthly");
|
|
381
381
|
await userEvent.click(enterpriseButton);
|
|
382
|
+
expect(onPlanChange).not.toHaveBeenCalled();
|
|
383
|
+
await userEvent.click(await screen.findByTestId("plan-change-confirm-button"));
|
|
382
384
|
|
|
383
385
|
expect(onPlanChange).toHaveBeenCalledWith(
|
|
384
|
-
expect.objectContaining({ slug: "enterprise-monthly" })
|
|
386
|
+
expect.objectContaining({ slug: "enterprise-monthly" }),
|
|
387
|
+
null,
|
|
385
388
|
);
|
|
386
389
|
});
|
|
387
390
|
|
|
@@ -408,7 +411,9 @@ describe("SubscriptionManager", () => {
|
|
|
408
411
|
expect(screen.getByText(/manage billing/i)).toBeDefined();
|
|
409
412
|
});
|
|
410
413
|
|
|
411
|
-
|
|
414
|
+
// A signed-in user with no subscription row is on the free plan — the 404
|
|
415
|
+
// path only happens once the session is accepted (crochet_patterns-34n7).
|
|
416
|
+
it("shows the free plan as theirs when no subscription exists", async () => {
|
|
412
417
|
const fetcher = mockFetchRouter({
|
|
413
418
|
"/billing/subscription/current/": null, // 404 - no subscription
|
|
414
419
|
"/billing/products/": mockProduct,
|
|
@@ -425,8 +430,9 @@ describe("SubscriptionManager", () => {
|
|
|
425
430
|
);
|
|
426
431
|
|
|
427
432
|
await waitFor(() => {
|
|
428
|
-
expect(screen.getByText(/
|
|
433
|
+
expect(screen.getByText(/you're on the starter plan/i)).toBeDefined();
|
|
429
434
|
});
|
|
435
|
+
expect(screen.queryByText(/no active subscription/i)).toBeNull();
|
|
430
436
|
});
|
|
431
437
|
|
|
432
438
|
it("shows subscribe options when no subscription", async () => {
|
|
@@ -591,7 +597,8 @@ describe("SubscriptionManager", () => {
|
|
|
591
597
|
const trialNodes = screen.getAllByTestId("trial-copy");
|
|
592
598
|
expect(trialNodes.length).toBe(1);
|
|
593
599
|
expect(trialNodes[0].textContent).toMatch(/14-day free trial/i);
|
|
594
|
-
|
|
600
|
+
// Checkout takes a card up front, so never promise otherwise (crochet_patterns-34n7).
|
|
601
|
+
expect(trialNodes[0].textContent).not.toMatch(/no card/i);
|
|
595
602
|
});
|
|
596
603
|
|
|
597
604
|
it("renders 'Unlimited X' for features with limit === -1 (o7u)", async () => {
|
|
@@ -631,8 +638,10 @@ describe("SubscriptionManager", () => {
|
|
|
631
638
|
|
|
632
639
|
it("free-offer CTA calls subscribe-free, not Stripe checkout (6vk)", async () => {
|
|
633
640
|
const onPlanChange = vi.fn();
|
|
641
|
+
// On Pro, so the Free card is a downgrade and clickable (with no
|
|
642
|
+
// subscription Free is the current plan and its CTA is disabled).
|
|
634
643
|
const fetcher = mockFetchRouter({
|
|
635
|
-
"/billing/subscription/current/":
|
|
644
|
+
"/billing/subscription/current/": mockSubscriptionActive,
|
|
636
645
|
"/billing/products/": mockProduct,
|
|
637
646
|
});
|
|
638
647
|
const Wrapper = createWrapper(fetcher);
|
|
@@ -649,8 +658,10 @@ describe("SubscriptionManager", () => {
|
|
|
649
658
|
|
|
650
659
|
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
651
660
|
|
|
652
|
-
// Click the Free card's CTA (slug "starter-monthly", isFree=true)
|
|
653
|
-
|
|
661
|
+
// Click the Free card's CTA (slug "starter-monthly", isFree=true), then
|
|
662
|
+
// confirm: leaving a paid plan is never one click (crochet_patterns-lr7mj).
|
|
663
|
+
await userEvent.click(await screen.findByTestId("upgrade-cta-starter-monthly"));
|
|
664
|
+
await userEvent.click(await screen.findByTestId("plan-change-confirm-button"));
|
|
654
665
|
|
|
655
666
|
// subscribe-free is hit (server fetcher saw the POST)
|
|
656
667
|
await waitFor(() => {
|
|
@@ -785,11 +796,12 @@ describe("SubscriptionManager", () => {
|
|
|
785
796
|
};
|
|
786
797
|
|
|
787
798
|
const cases: Case[] = [
|
|
788
|
-
// No subscription (
|
|
789
|
-
|
|
799
|
+
// No subscription row (signed in, never enrolled): they are on the free
|
|
800
|
+
// plan, so it reads Current plan and is disabled (crochet_patterns-34n7).
|
|
801
|
+
{ label: "no-sub · Starter card → 'Current plan' disabled badge",
|
|
790
802
|
subscription: null, cardSlug: "starter-monthly",
|
|
791
|
-
expectedCta: /
|
|
792
|
-
shouldShowCurrentBadge:
|
|
803
|
+
expectedCta: /current plan/i, shouldBeDisabled: true,
|
|
804
|
+
shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
|
|
793
805
|
{ label: "no-sub · Pro card → 'Start Trial' (upgrade)",
|
|
794
806
|
subscription: null, cardSlug: "pro-monthly",
|
|
795
807
|
expectedCta: /start trial/i, shouldBeDisabled: false,
|
|
@@ -823,10 +835,12 @@ describe("SubscriptionManager", () => {
|
|
|
823
835
|
cardSlug: "pro-monthly",
|
|
824
836
|
expectedCta: /current plan/i, shouldBeDisabled: true,
|
|
825
837
|
shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
|
|
826
|
-
|
|
838
|
+
// Already paying: an upgrade changes the plan in place, so it says so
|
|
839
|
+
// instead of the new-buyer pitch (crochet_patterns-anw61).
|
|
840
|
+
{ label: "pro · Enterprise card → 'Upgrade to Enterprise', no trial copy",
|
|
827
841
|
subscription: mockSubscriptionActive,
|
|
828
842
|
cardSlug: "enterprise-monthly",
|
|
829
|
-
expectedCta: /
|
|
843
|
+
expectedCta: /upgrade to enterprise/i, shouldBeDisabled: false,
|
|
830
844
|
shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
|
|
831
845
|
|
|
832
846
|
// TRIALING Pro current sub
|
|
@@ -958,8 +972,10 @@ describe("SubscriptionManager", () => {
|
|
|
958
972
|
</Wrapper>
|
|
959
973
|
);
|
|
960
974
|
|
|
961
|
-
//
|
|
962
|
-
|
|
975
|
+
// No subscription found yet: "Activating…", never "activated" beside
|
|
976
|
+
// Free (crochet_patterns-nag60).
|
|
977
|
+
await waitFor(() => expect(screen.getByTestId("status-banner-activating")).toBeDefined());
|
|
978
|
+
expect(screen.queryByTestId("status-banner-success")).toBeNull();
|
|
963
979
|
|
|
964
980
|
// success-sync was hit
|
|
965
981
|
await waitFor(() => {
|
|
@@ -986,7 +1002,10 @@ describe("SubscriptionManager", () => {
|
|
|
986
1002
|
</Wrapper>
|
|
987
1003
|
);
|
|
988
1004
|
|
|
989
|
-
await waitFor(() =>
|
|
1005
|
+
await waitFor(() => {
|
|
1006
|
+
const calls = (fetcher as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
|
|
1007
|
+
expect(calls.some((u) => u.includes("/billing/success-sync/"))).toBe(true);
|
|
1008
|
+
});
|
|
990
1009
|
rerender(
|
|
991
1010
|
<Wrapper>
|
|
992
1011
|
<SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" statusMessage="success" />
|
|
@@ -1274,9 +1293,9 @@ describe("SubscriptionManager", () => {
|
|
|
1274
1293
|
</Wrapper>
|
|
1275
1294
|
);
|
|
1276
1295
|
|
|
1277
|
-
// No crash; falls through to the
|
|
1296
|
+
// No crash; falls through to the free-plan copy.
|
|
1278
1297
|
await waitFor(() =>
|
|
1279
|
-
expect(screen.getByText(
|
|
1298
|
+
expect(screen.getByText(/you're on the starter plan/i)).toBeDefined()
|
|
1280
1299
|
);
|
|
1281
1300
|
});
|
|
1282
1301
|
|