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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy