Skip to content

Commit 48be55b

Browse files
authored
chore(clerk-js): Use error metadata for invalid change plan screen on <Checkout /> (#6102)
1 parent b0e47f1 commit 48be55b

File tree

12 files changed

+138
-42
lines changed

12 files changed

+138
-42
lines changed

.changeset/itchy-keys-shake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/testing': patch
3+
---
4+
5+
Bug fix: Toggling the period switch would not match the requested period `startCheckout({ period })`.

.changeset/rotten-ghosts-build.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Use error metadata for invalid change plan screen on `Checkout` component.

.changeset/sad-lines-share.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/shared': patch
3+
'@clerk/types': patch
4+
---
5+
6+
Parse partial `plan` in `ClerkAPIError.meta`

integration/tests/pricing-table.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,5 +316,38 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })('pricing tabl
316316

317317
await fakeUser.deleteIfExists();
318318
});
319+
320+
test('displays notice then plan cannot change', async ({ page, context }) => {
321+
const u = createTestUtils({ app, page, context });
322+
323+
const fakeUser = u.services.users.createFakeUser();
324+
await u.services.users.createBapiUser(fakeUser);
325+
326+
await u.po.signIn.goTo();
327+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
328+
await u.po.page.goToRelative('/user');
329+
330+
await u.po.userProfile.waitForMounted();
331+
await u.po.userProfile.switchToBillingTab();
332+
await u.po.page.getByRole('button', { name: 'Switch plans' }).click();
333+
await u.po.pricingTable.startCheckout({ planSlug: 'plus', period: 'annually' });
334+
await u.po.checkout.waitForMounted();
335+
await u.po.checkout.fillTestCard();
336+
await u.po.checkout.clickPayOrSubscribe();
337+
await expect(u.po.page.getByText('Payment was successful!')).toBeVisible();
338+
339+
await u.po.checkout.confirmAndContinue();
340+
await u.po.pricingTable.startCheckout({ planSlug: 'pro', shouldSwitch: true, period: 'monthly' });
341+
await u.po.checkout.waitForMounted();
342+
await expect(
343+
page
344+
.locator('.cl-checkout-root')
345+
.getByText(
346+
'You cannot subscribe to this plan by paying monthly. To subscribe to this plan, you need to choose to pay annually',
347+
),
348+
).toBeVisible();
349+
350+
await fakeUser.deleteIfExists();
351+
});
319352
});
320353
});

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"files": [
33
{ "path": "./dist/clerk.js", "maxSize": "605kB" },
4-
{ "path": "./dist/clerk.browser.js", "maxSize": "69.2KB" },
4+
{ "path": "./dist/clerk.browser.js", "maxSize": "69.3KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "113KB" },
66
{ "path": "./dist/clerk.headless*.js", "maxSize": "53KB" },
77
{ "path": "./dist/ui-common*.js", "maxSize": "106.3KB" },

packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { useClerk, useOrganization, useUser } from '@clerk/shared/react';
2-
import type { ClerkAPIError, CommerceCheckoutResource, CommercePlanResource } from '@clerk/types';
2+
import type { ClerkAPIError, CommerceCheckoutResource } from '@clerk/types';
33
import { createContext, useContext, useEffect, useMemo } from 'react';
44
import useSWR from 'swr';
55
import useSWRMutation from 'swr/mutation';
66

7-
import { useCheckoutContext, usePlans } from '../../contexts';
7+
import { useCheckoutContext } from '../../contexts';
88

99
type CheckoutStatus = 'pending' | 'ready' | 'completed' | 'missing_payer_email' | 'invalid_plan_change' | 'error';
1010

@@ -14,7 +14,6 @@ const CheckoutContextRoot = createContext<{
1414
updateCheckout: (checkout: CommerceCheckoutResource) => void;
1515
errors: ClerkAPIError[];
1616
startCheckout: () => void;
17-
plan: CommercePlanResource | undefined;
1817
status: CheckoutStatus;
1918
} | null>(null);
2019

@@ -88,16 +87,10 @@ const useCheckoutCreator = () => {
8887
};
8988

9089
const Root = ({ children }: { children: React.ReactNode }) => {
91-
const { planId } = useCheckoutContext();
92-
const { data: plans, isLoading: plansLoading } = usePlans();
9390
const { checkout, isMutating, updateCheckout, errors, startCheckout } = useCheckoutCreator();
9491

95-
const plan = plans?.find(p => p.id === planId);
96-
97-
const isLoading = isMutating || plansLoading;
98-
9992
const status = useMemo(() => {
100-
if (isLoading) return 'pending';
93+
if (isMutating) return 'pending';
10194
const completedCode = 'completed';
10295
if (checkout?.status === completedCode) return completedCode;
10396
if (checkout) return 'ready';
@@ -106,19 +99,18 @@ const Root = ({ children }: { children: React.ReactNode }) => {
10699
const isMissingPayerEmail = !!errors?.some((e: ClerkAPIError) => e.code === missingCode);
107100
if (isMissingPayerEmail) return missingCode;
108101
const invalidChangeCode = 'invalid_plan_change';
109-
if (errors?.[0]?.code === invalidChangeCode && plan) return invalidChangeCode;
102+
if (errors?.[0]?.code === invalidChangeCode) return invalidChangeCode;
110103
return 'error';
111-
}, [isLoading, errors, checkout, plan?.id, checkout?.status]);
104+
}, [isMutating, errors, checkout, checkout?.status]);
112105

113106
return (
114107
<CheckoutContextRoot.Provider
115108
value={{
116109
checkout,
117-
isLoading,
110+
isLoading: isMutating,
118111
updateCheckout,
119112
errors,
120113
startCheckout,
121-
plan,
122114
status,
123115
}}
124116
>

packages/clerk-js/src/ui/components/Checkout/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Flow, localizationKeys, Spinner } from '../../customizables';
77
import { CheckoutComplete } from './CheckoutComplete';
88
import { CheckoutForm } from './CheckoutForm';
99
import * as CheckoutPage from './CheckoutPage';
10-
import { AddEmailForm, GenericError, InvalidPlanError } from './parts';
10+
import { AddEmailForm, GenericError, InvalidPlanScreen } from './parts';
1111

1212
export const Checkout = (props: __internal_CheckoutProps) => {
1313
return (
@@ -40,7 +40,7 @@ export const Checkout = (props: __internal_CheckoutProps) => {
4040
</CheckoutPage.Stage>
4141

4242
<CheckoutPage.Stage name='invalid_plan_change'>
43-
<InvalidPlanError />
43+
<InvalidPlanScreen />
4444
</CheckoutPage.Stage>
4545

4646
<CheckoutPage.Stage name='missing_payer_email'>

packages/clerk-js/src/ui/components/Checkout/parts.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useMemo } from 'react';
2+
13
import { Alert } from '@/ui/elements/Alert';
24
import { Drawer, useDrawerContext } from '@/ui/elements/Drawer';
35
import { LineItems } from '@/ui/elements/LineItems';
@@ -34,11 +36,17 @@ export const GenericError = () => {
3436
);
3537
};
3638

37-
export const InvalidPlanError = () => {
38-
const { plan } = useCheckoutContextRoot();
39+
export const InvalidPlanScreen = () => {
40+
const { errors } = useCheckoutContextRoot();
41+
42+
const planFromError = useMemo(() => {
43+
const error = errors?.find(e => e.code === 'invalid_plan_change');
44+
return error?.meta?.plan;
45+
}, [errors]);
46+
3947
const { planPeriod } = useCheckoutContext();
4048

41-
if (!plan) {
49+
if (!planFromError) {
4250
return null;
4351
}
4452

@@ -60,12 +68,12 @@ export const InvalidPlanError = () => {
6068
<LineItems.Root>
6169
<LineItems.Group>
6270
<LineItems.Title
63-
title={plan.name}
71+
title={planFromError.name}
6472
description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
6573
/>
6674
<LineItems.Description
6775
prefix={planPeriod === 'annual' ? 'x12' : undefined}
68-
text={`${plan.currencySymbol}${planPeriod === 'month' ? plan.amountFormatted : plan.annualMonthlyAmountFormatted}`}
76+
text={`${planFromError.currency_symbol}${planPeriod === 'month' ? planFromError.amount_formatted : planFromError.annual_monthly_amount_formatted}`}
6977
suffix={localizationKeys('commerce.checkout.perMonth')}
7078
/>
7179
</LineItems.Group>

packages/shared/src/error.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export function parseError(error: ClerkAPIErrorJSON): ClerkAPIError {
9595
emailAddresses: error?.meta?.email_addresses,
9696
identifiers: error?.meta?.identifiers,
9797
zxcvbn: error?.meta?.zxcvbn,
98+
plan: error?.meta?.plan,
9899
},
99100
};
100101
}
@@ -110,6 +111,7 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON {
110111
email_addresses: error?.meta?.emailAddresses,
111112
identifiers: error?.meta?.identifiers,
112113
zxcvbn: error?.meta?.zxcvbn,
114+
plan: error?.meta?.plan,
113115
},
114116
};
115117
}

packages/testing/src/playwright/unstable/page-objects/pricingTable.ts

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,66 @@
11
import type { EnhancedPage } from './app';
22
import { common } from './common';
33

4+
type BillingPeriod = 'monthly' | 'annually';
5+
46
export const createPricingTablePageObject = (testArgs: { page: EnhancedPage }) => {
57
const { page } = testArgs;
8+
9+
const locators = {
10+
toggle: (planSlug: string) => page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardPeriodToggle`),
11+
indicator: (planSlug: string) => page.locator(`.cl-pricingTableCard__${planSlug} .cl-switchIndicator`),
12+
badge: (planSlug: string) => page.locator(`.cl-pricingTableCard__${planSlug} .cl-badge`),
13+
footer: (planSlug: string) => page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardFooter`),
14+
};
15+
16+
const ensurePricingPeriod = async (planSlug: string, period: BillingPeriod): Promise<void> => {
17+
async function waitForAttribute(selector: string, attribute: string, value: string, timeout = 5000) {
18+
return page
19+
.waitForFunction(
20+
({ sel, attr, val }) => {
21+
const element = document.querySelector(sel);
22+
return element?.getAttribute(attr) === val;
23+
},
24+
{ sel: selector, attr: attribute, val: value },
25+
{ timeout },
26+
)
27+
.then(() => {
28+
return true;
29+
})
30+
.catch(() => {
31+
return false;
32+
});
33+
}
34+
35+
const isAnnually = await waitForAttribute(
36+
`.cl-pricingTableCard__${planSlug} .cl-switchIndicator`,
37+
'data-checked',
38+
'true',
39+
500,
40+
);
41+
42+
if (isAnnually && period === 'monthly') {
43+
await locators.toggle(planSlug).click();
44+
}
45+
46+
if (!isAnnually && period === 'annually') {
47+
await locators.toggle(planSlug).click();
48+
}
49+
};
50+
651
const self = {
752
...common(testArgs),
853
waitForMounted: (selector = '.cl-pricingTable-root') => {
954
return page.waitForSelector(selector, { state: 'attached' });
1055
},
11-
// clickManageSubscription: async () => {
12-
// await page.getByText('Manage subscription').click();
13-
// },
1456
clickResubscribe: async () => {
1557
await page.getByText('Re-subscribe').click();
1658
},
1759
waitToBeActive: async ({ planSlug }: { planSlug: string }) => {
18-
return page
19-
.locator(`.cl-pricingTableCard__${planSlug} .cl-badge`)
20-
.getByText('Active')
21-
.waitFor({ state: 'visible' });
60+
return locators.badge(planSlug).getByText('Active').waitFor({ state: 'visible' });
2261
},
2362
getPlanCardCTA: ({ planSlug }: { planSlug: string }) => {
24-
return page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardFooter`).getByRole('button', {
63+
return locators.footer(planSlug).getByRole('button', {
2564
name: /get|switch|subscribe/i,
2665
});
2766
},
@@ -32,25 +71,17 @@ export const createPricingTablePageObject = (testArgs: { page: EnhancedPage }) =
3271
}: {
3372
planSlug: string;
3473
shouldSwitch?: boolean;
35-
period?: 'monthly' | 'annually';
74+
period?: BillingPeriod;
3675
}) => {
3776
const targetButtonName =
3877
shouldSwitch === true ? 'Switch to this plan' : shouldSwitch === false ? /subscribe/i : /get|switch|subscribe/i;
3978

4079
if (period) {
41-
await page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardPeriodToggle`).click();
42-
43-
const billedAnnuallyChecked = await page
44-
.locator(`.cl-pricingTableCard__${planSlug} .cl-switchIndicator`)
45-
.getAttribute('data-checked');
46-
47-
if (billedAnnuallyChecked === 'true' && period === 'monthly') {
48-
await page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardPeriodToggle`).click();
49-
}
80+
await ensurePricingPeriod(planSlug, period);
5081
}
5182

52-
await page
53-
.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardFooter`)
83+
await locators
84+
.footer(planSlug)
5485
.getByRole('button', {
5586
name: targetButtonName,
5687
})

0 commit comments

Comments
 (0)