diff --git a/.changeset/itchy-keys-shake.md b/.changeset/itchy-keys-shake.md new file mode 100644 index 00000000000..f2fb59f7e72 --- /dev/null +++ b/.changeset/itchy-keys-shake.md @@ -0,0 +1,5 @@ +--- +'@clerk/testing': patch +--- + +Bug fix: Toggling the period switch would not match the requested period `startCheckout({ period })`. diff --git a/.changeset/rotten-ghosts-build.md b/.changeset/rotten-ghosts-build.md new file mode 100644 index 00000000000..55d6745c9b2 --- /dev/null +++ b/.changeset/rotten-ghosts-build.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Use error metadata for invalid change plan screen on `Checkout` component. diff --git a/.changeset/sad-lines-share.md b/.changeset/sad-lines-share.md new file mode 100644 index 00000000000..1b515296155 --- /dev/null +++ b/.changeset/sad-lines-share.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': patch +'@clerk/types': patch +--- + +Parse partial `plan` in `ClerkAPIError.meta` diff --git a/integration/tests/pricing-table.test.ts b/integration/tests/pricing-table.test.ts index ab47943eace..bbd70bf845c 100644 --- a/integration/tests/pricing-table.test.ts +++ b/integration/tests/pricing-table.test.ts @@ -316,5 +316,38 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })('pricing tabl await fakeUser.deleteIfExists(); }); + + test('displays notice then plan cannot change', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + const fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.page.goToRelative('/user'); + + await u.po.userProfile.waitForMounted(); + await u.po.userProfile.switchToBillingTab(); + await u.po.page.getByRole('button', { name: 'Switch plans' }).click(); + await u.po.pricingTable.startCheckout({ planSlug: 'plus', period: 'annually' }); + await u.po.checkout.waitForMounted(); + await u.po.checkout.fillTestCard(); + await u.po.checkout.clickPayOrSubscribe(); + await expect(u.po.page.getByText('Payment was successful!')).toBeVisible(); + + await u.po.checkout.confirmAndContinue(); + await u.po.pricingTable.startCheckout({ planSlug: 'pro', shouldSwitch: true, period: 'monthly' }); + await u.po.checkout.waitForMounted(); + await expect( + page + .locator('.cl-checkout-root') + .getByText( + 'You cannot subscribe to this plan by paying monthly. To subscribe to this plan, you need to choose to pay annually', + ), + ).toBeVisible(); + + await fakeUser.deleteIfExists(); + }); }); }); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index a446de1a2ba..28817f657c7 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ { "path": "./dist/clerk.js", "maxSize": "605kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "69.2KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "69.3KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "113KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "53KB" }, { "path": "./dist/ui-common*.js", "maxSize": "106.3KB" }, diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx index 018d9a607cb..6d8f2747ee2 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -1,10 +1,10 @@ import { useClerk, useOrganization, useUser } from '@clerk/shared/react'; -import type { ClerkAPIError, CommerceCheckoutResource, CommercePlanResource } from '@clerk/types'; +import type { ClerkAPIError, CommerceCheckoutResource } from '@clerk/types'; import { createContext, useContext, useEffect, useMemo } from 'react'; import useSWR from 'swr'; import useSWRMutation from 'swr/mutation'; -import { useCheckoutContext, usePlans } from '../../contexts'; +import { useCheckoutContext } from '../../contexts'; type CheckoutStatus = 'pending' | 'ready' | 'completed' | 'missing_payer_email' | 'invalid_plan_change' | 'error'; @@ -14,7 +14,6 @@ const CheckoutContextRoot = createContext<{ updateCheckout: (checkout: CommerceCheckoutResource) => void; errors: ClerkAPIError[]; startCheckout: () => void; - plan: CommercePlanResource | undefined; status: CheckoutStatus; } | null>(null); @@ -88,16 +87,10 @@ const useCheckoutCreator = () => { }; const Root = ({ children }: { children: React.ReactNode }) => { - const { planId } = useCheckoutContext(); - const { data: plans, isLoading: plansLoading } = usePlans(); const { checkout, isMutating, updateCheckout, errors, startCheckout } = useCheckoutCreator(); - const plan = plans?.find(p => p.id === planId); - - const isLoading = isMutating || plansLoading; - const status = useMemo(() => { - if (isLoading) return 'pending'; + if (isMutating) return 'pending'; const completedCode = 'completed'; if (checkout?.status === completedCode) return completedCode; if (checkout) return 'ready'; @@ -106,19 +99,18 @@ const Root = ({ children }: { children: React.ReactNode }) => { const isMissingPayerEmail = !!errors?.some((e: ClerkAPIError) => e.code === missingCode); if (isMissingPayerEmail) return missingCode; const invalidChangeCode = 'invalid_plan_change'; - if (errors?.[0]?.code === invalidChangeCode && plan) return invalidChangeCode; + if (errors?.[0]?.code === invalidChangeCode) return invalidChangeCode; return 'error'; - }, [isLoading, errors, checkout, plan?.id, checkout?.status]); + }, [isMutating, errors, checkout, checkout?.status]); return ( diff --git a/packages/clerk-js/src/ui/components/Checkout/index.tsx b/packages/clerk-js/src/ui/components/Checkout/index.tsx index c37a1dc80bf..67c37daecc4 100644 --- a/packages/clerk-js/src/ui/components/Checkout/index.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/index.tsx @@ -7,7 +7,7 @@ import { Flow, localizationKeys, Spinner } from '../../customizables'; import { CheckoutComplete } from './CheckoutComplete'; import { CheckoutForm } from './CheckoutForm'; import * as CheckoutPage from './CheckoutPage'; -import { AddEmailForm, GenericError, InvalidPlanError } from './parts'; +import { AddEmailForm, GenericError, InvalidPlanScreen } from './parts'; export const Checkout = (props: __internal_CheckoutProps) => { return ( @@ -40,7 +40,7 @@ export const Checkout = (props: __internal_CheckoutProps) => { - + diff --git a/packages/clerk-js/src/ui/components/Checkout/parts.tsx b/packages/clerk-js/src/ui/components/Checkout/parts.tsx index e7be1c95466..4fd79a91562 100644 --- a/packages/clerk-js/src/ui/components/Checkout/parts.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/parts.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react'; + import { Alert } from '@/ui/elements/Alert'; import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; import { LineItems } from '@/ui/elements/LineItems'; @@ -34,11 +36,17 @@ export const GenericError = () => { ); }; -export const InvalidPlanError = () => { - const { plan } = useCheckoutContextRoot(); +export const InvalidPlanScreen = () => { + const { errors } = useCheckoutContextRoot(); + + const planFromError = useMemo(() => { + const error = errors?.find(e => e.code === 'invalid_plan_change'); + return error?.meta?.plan; + }, [errors]); + const { planPeriod } = useCheckoutContext(); - if (!plan) { + if (!planFromError) { return null; } @@ -60,12 +68,12 @@ export const InvalidPlanError = () => { diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 8fc2c255ba4..77f2479e516 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -95,6 +95,7 @@ export function parseError(error: ClerkAPIErrorJSON): ClerkAPIError { emailAddresses: error?.meta?.email_addresses, identifiers: error?.meta?.identifiers, zxcvbn: error?.meta?.zxcvbn, + plan: error?.meta?.plan, }, }; } @@ -110,6 +111,7 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { email_addresses: error?.meta?.emailAddresses, identifiers: error?.meta?.identifiers, zxcvbn: error?.meta?.zxcvbn, + plan: error?.meta?.plan, }, }; } diff --git a/packages/testing/src/playwright/unstable/page-objects/pricingTable.ts b/packages/testing/src/playwright/unstable/page-objects/pricingTable.ts index da5f2ff78de..113cc714db4 100644 --- a/packages/testing/src/playwright/unstable/page-objects/pricingTable.ts +++ b/packages/testing/src/playwright/unstable/page-objects/pricingTable.ts @@ -1,27 +1,66 @@ import type { EnhancedPage } from './app'; import { common } from './common'; +type BillingPeriod = 'monthly' | 'annually'; + export const createPricingTablePageObject = (testArgs: { page: EnhancedPage }) => { const { page } = testArgs; + + const locators = { + toggle: (planSlug: string) => page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardPeriodToggle`), + indicator: (planSlug: string) => page.locator(`.cl-pricingTableCard__${planSlug} .cl-switchIndicator`), + badge: (planSlug: string) => page.locator(`.cl-pricingTableCard__${planSlug} .cl-badge`), + footer: (planSlug: string) => page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardFooter`), + }; + + const ensurePricingPeriod = async (planSlug: string, period: BillingPeriod): Promise => { + async function waitForAttribute(selector: string, attribute: string, value: string, timeout = 5000) { + return page + .waitForFunction( + ({ sel, attr, val }) => { + const element = document.querySelector(sel); + return element?.getAttribute(attr) === val; + }, + { sel: selector, attr: attribute, val: value }, + { timeout }, + ) + .then(() => { + return true; + }) + .catch(() => { + return false; + }); + } + + const isAnnually = await waitForAttribute( + `.cl-pricingTableCard__${planSlug} .cl-switchIndicator`, + 'data-checked', + 'true', + 500, + ); + + if (isAnnually && period === 'monthly') { + await locators.toggle(planSlug).click(); + } + + if (!isAnnually && period === 'annually') { + await locators.toggle(planSlug).click(); + } + }; + const self = { ...common(testArgs), waitForMounted: (selector = '.cl-pricingTable-root') => { return page.waitForSelector(selector, { state: 'attached' }); }, - // clickManageSubscription: async () => { - // await page.getByText('Manage subscription').click(); - // }, clickResubscribe: async () => { await page.getByText('Re-subscribe').click(); }, waitToBeActive: async ({ planSlug }: { planSlug: string }) => { - return page - .locator(`.cl-pricingTableCard__${planSlug} .cl-badge`) - .getByText('Active') - .waitFor({ state: 'visible' }); + return locators.badge(planSlug).getByText('Active').waitFor({ state: 'visible' }); }, getPlanCardCTA: ({ planSlug }: { planSlug: string }) => { - return page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardFooter`).getByRole('button', { + return locators.footer(planSlug).getByRole('button', { name: /get|switch|subscribe/i, }); }, @@ -32,25 +71,17 @@ export const createPricingTablePageObject = (testArgs: { page: EnhancedPage }) = }: { planSlug: string; shouldSwitch?: boolean; - period?: 'monthly' | 'annually'; + period?: BillingPeriod; }) => { const targetButtonName = shouldSwitch === true ? 'Switch to this plan' : shouldSwitch === false ? /subscribe/i : /get|switch|subscribe/i; if (period) { - await page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardPeriodToggle`).click(); - - const billedAnnuallyChecked = await page - .locator(`.cl-pricingTableCard__${planSlug} .cl-switchIndicator`) - .getAttribute('data-checked'); - - if (billedAnnuallyChecked === 'true' && period === 'monthly') { - await page.locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardPeriodToggle`).click(); - } + await ensurePricingPeriod(planSlug, period); } - await page - .locator(`.cl-pricingTableCard__${planSlug} .cl-pricingTableCardFooter`) + await locators + .footer(planSlug) .getByRole('button', { name: targetButtonName, }) diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index dbe74b42fc3..80a96be7a51 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -29,6 +29,13 @@ export interface ClerkAPIError { }[]; }; permissions?: string[]; + plan?: { + amount_formatted: string; + annual_monthly_amount_formatted: string; + currency_symbol: string; + id: string; + name: string; + }; }; } diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 2bbfeed77f4..eb589e1bdbf 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -349,6 +349,13 @@ export interface ClerkAPIErrorJSON { message: string; }[]; }; + plan?: { + amount_formatted: string; + annual_monthly_amount_formatted: string; + currency_symbol: string; + id: string; + name: string; + }; }; }