diff --git a/packages/template-retail-react-app/app/hooks/use-basket-recovery.js b/packages/template-retail-react-app/app/hooks/use-basket-recovery.js deleted file mode 100644 index b895f9e71c..0000000000 --- a/packages/template-retail-react-app/app/hooks/use-basket-recovery.js +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import {useCommerceApi} from '@salesforce/commerce-sdk-react' -import useAuthContext from '@salesforce/commerce-sdk-react/hooks/useAuthContext' -import {useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' - -// Dev-only debug logger to keep recovery silent in production -const devDebug = (...args) => { - if (process.env.NODE_ENV !== 'production') { - console.debug(...args) - } -} - -/** - * Reusable basket recovery hook to stabilize basket after OTP/auth swap. - * - Attempts merge (if caller already merged, pass skipMerge=true) - * - Hydrates destination basket by id with retry - * - Fallbacks to create/copy items and re-apply shipping - */ -const useBasketRecovery = () => { - const api = useCommerceApi() - const auth = useAuthContext() - - const mergeBasket = useShopperBasketsMutation('mergeBasket') - const createBasket = useShopperBasketsMutation('createBasket') - const addItemToBasket = useShopperBasketsMutation('addItemToBasket') - const updateShippingAddressForShipment = useShopperBasketsMutation( - 'updateShippingAddressForShipment' - ) - const updateShippingMethodForShipment = useShopperBasketsMutation( - 'updateShippingMethodForShipment' - ) - - const copyItemsAndShipping = async ( - destinationBasketId, - items = [], - shipment = null, - shipmentId = 'me' - ) => { - if (items?.length) { - const payload = items.map((item) => { - const productId = item.productId || item.product_id || item.id || item.product?.id - const quantity = item.quantity || item.amount || 1 - const variationAttributes = - item.variationAttributes || item.variation_attributes || [] - const optionItems = item.optionItems || item.option_items || [] - const mappedVariations = Array.isArray(variationAttributes) - ? variationAttributes.map((v) => ({ - attributeId: v.attributeId || v.attribute_id || v.id, - valueId: v.valueId || v.value_id || v.value - })) - : [] - const mappedOptions = Array.isArray(optionItems) - ? optionItems.map((o) => ({ - optionId: o.optionId || o.option_id || o.id, - optionValueId: - o.optionValueId || o.optionValue || o.option_value || o.value - })) - : [] - const obj = {productId, quantity} - if (mappedVariations.length) obj.variationAttributes = mappedVariations - if (mappedOptions.length) obj.optionItems = mappedOptions - return obj - }) - await addItemToBasket.mutateAsync({ - parameters: {basketId: destinationBasketId}, - body: payload - }) - } - - if (shipment) { - const shippingAddress = shipment.shippingAddress - if (shippingAddress) { - await updateShippingAddressForShipment.mutateAsync({ - parameters: {basketId: destinationBasketId, shipmentId}, - body: { - address1: shippingAddress.address1, - address2: shippingAddress.address2, - city: shippingAddress.city, - countryCode: shippingAddress.countryCode, - firstName: shippingAddress.firstName, - lastName: shippingAddress.lastName, - phone: shippingAddress.phone, - postalCode: shippingAddress.postalCode, - stateCode: shippingAddress.stateCode - } - }) - } - const methodId = shipment?.shippingMethod?.id - if (methodId) { - await updateShippingMethodForShipment.mutateAsync({ - parameters: {basketId: destinationBasketId, shipmentId}, - body: {id: methodId} - }) - } - } - } - - const recoverBasketAfterAuth = async ({ - preLoginItems = [], - shipment = null, - doMerge = true - } = {}) => { - // Ensure fresh token in provider - await auth.refreshAccessToken() - - let destinationBasketId - if (doMerge) { - try { - const merged = await mergeBasket.mutateAsync({ - parameters: {createDestinationBasket: true} - }) - destinationBasketId = merged?.basketId || merged?.basket_id || merged?.id - } catch (_e) { - devDebug('useBasketRecovery: mergeBasket failed; proceeding without merge', _e) - } - } - - if (!destinationBasketId) { - try { - const list = await api.shopperCustomers.getCustomerBaskets({ - parameters: {customerId: 'me'} - }) - destinationBasketId = list?.baskets?.[0]?.basketId - } catch (_e) { - devDebug( - 'useBasketRecovery: getCustomerBaskets failed; will attempt hydration/create', - _e - ) - } - } - - if (destinationBasketId) { - // Avoid triggering a hook-level refetch that can cause UI remounts. - // Instead, probe the destination basket directly for shipment id. - let hydrated = null - try { - hydrated = await api.shopperBaskets.getBasket({ - headers: {authorization: `Bearer ${auth.get('access_token')}`}, - parameters: {basketId: destinationBasketId} - }) - } catch (_e) { - devDebug('useBasketRecovery: getBasket hydration failed', _e) - hydrated = null - } - if (!hydrated) { - try { - const created = await createBasket.mutateAsync({}) - destinationBasketId = - created?.basketId || - created?.basket_id || - created?.id || - destinationBasketId - await copyItemsAndShipping(destinationBasketId, preLoginItems, shipment) - } catch (_e) { - devDebug( - 'useBasketRecovery: createBasket/copyItems failed during hydration path', - _e - ) - } - } else if (shipment) { - // PII (shipping address/method) is not merged by API; re-apply from snapshot - try { - const effectiveDestId = hydrated?.basketId || destinationBasketId - const destShipmentId = - hydrated?.shipments?.[0]?.shipmentId || hydrated?.shipments?.[0]?.id || 'me' - await copyItemsAndShipping(effectiveDestId, [], shipment, destShipmentId) - } catch (_e) { - devDebug('useBasketRecovery: re-applying shipping from snapshot failed', _e) - } - } - } else { - try { - const created = await createBasket.mutateAsync({}) - destinationBasketId = created?.basketId || created?.basket_id || created?.id - await copyItemsAndShipping(destinationBasketId, preLoginItems, shipment) - } catch (_e) { - devDebug('useBasketRecovery: createBasket/copyItems failed in fallback path', _e) - } - } - - return destinationBasketId - } - - return {recoverBasketAfterAuth} -} - -export default useBasketRecovery diff --git a/packages/template-retail-react-app/app/hooks/use-basket-recovery.test.js b/packages/template-retail-react-app/app/hooks/use-basket-recovery.test.js deleted file mode 100644 index 8ff0a6dca1..0000000000 --- a/packages/template-retail-react-app/app/hooks/use-basket-recovery.test.js +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import {renderHook, act} from '@testing-library/react' -import useBasketRecovery from '@salesforce/retail-react-app/app/hooks/use-basket-recovery' - -// Mocks -const mockInvalidate = jest.fn() -jest.mock('@tanstack/react-query', () => ({ - useQueryClient: () => ({invalidateQueries: mockInvalidate}) -})) - -let apiMock -const mockUseCommerceApi = jest.fn(() => apiMock) -const mockUseShopperBasketsMutation = jest.fn() -jest.mock('@salesforce/commerce-sdk-react', () => ({ - useCommerceApi: jest.fn((...args) => mockUseCommerceApi(...args)), - useShopperBasketsMutation: jest.fn((...args) => mockUseShopperBasketsMutation(...args)) -})) - -const mockAuth = { - refreshAccessToken: jest.fn(), - get: jest.fn(() => 'access-token') -} -jest.mock('@salesforce/commerce-sdk-react/hooks/useAuthContext', () => jest.fn(() => mockAuth)) - -describe('useBasketRecovery', () => { - let mergeBasket - let createBasket - let addItemToBasket - let updateShippingAddressForShipment - let updateShippingMethodForShipment - - beforeEach(() => { - jest.clearAllMocks() - - // api mock - apiMock = { - shopperCustomers: { - getCustomerBaskets: jest.fn() - }, - shopperBaskets: { - getBasket: jest.fn() - } - } - - // mutation mocks - returned based on name - mergeBasket = {mutateAsync: jest.fn()} - createBasket = {mutateAsync: jest.fn()} - addItemToBasket = {mutateAsync: jest.fn()} - updateShippingAddressForShipment = {mutateAsync: jest.fn()} - updateShippingMethodForShipment = {mutateAsync: jest.fn()} - - mockUseShopperBasketsMutation.mockImplementation((name) => { - switch (name) { - case 'mergeBasket': - return mergeBasket - case 'createBasket': - return createBasket - case 'addItemToBasket': - return addItemToBasket - case 'updateShippingAddressForShipment': - return updateShippingAddressForShipment - case 'updateShippingMethodForShipment': - return updateShippingMethodForShipment - default: - return {mutateAsync: jest.fn()} - } - }) - }) - - test('merges and re-applies shipping snapshot using hydrated shipment id', async () => { - mergeBasket.mutateAsync.mockResolvedValue({basketId: 'dest-1'}) - apiMock.shopperBaskets.getBasket.mockResolvedValue({ - basketId: 'dest-1', - shipments: [{shipmentId: 'shp-1'}] - }) - - const shipmentSnapshot = { - shippingAddress: { - address1: '5 Wall St', - city: 'Burlington', - countryCode: 'US', - firstName: 'S', - lastName: 'Y', - phone: '555-555-5555', - postalCode: '01803', - stateCode: 'MA' - }, - shippingMethod: {id: 'Ground'} - } - - const {result} = renderHook(() => useBasketRecovery()) - await act(async () => { - await result.current.recoverBasketAfterAuth({ - preLoginItems: [], - shipment: shipmentSnapshot, - doMerge: true - }) - }) - - expect(updateShippingAddressForShipment.mutateAsync).toHaveBeenCalledWith({ - parameters: {basketId: 'dest-1', shipmentId: 'shp-1'}, - body: expect.objectContaining({address1: '5 Wall St'}) - }) - expect(updateShippingMethodForShipment.mutateAsync).toHaveBeenCalledWith({ - parameters: {basketId: 'dest-1', shipmentId: 'shp-1'}, - body: {id: 'Ground'} - }) - // Invalidate may be elided in test env; existence is sufficient here - expect(typeof mockInvalidate).toBe('function') - }) - - test('fallback creates basket, copies items and re-applies shipping when hydrate fails', async () => { - // merge returns nothing; list returns a basket id; hydrate fails; create + copy - mergeBasket.mutateAsync.mockResolvedValue({}) - apiMock.shopperCustomers.getCustomerBaskets.mockResolvedValue({ - baskets: [{basketId: 'dest-x'}] - }) - apiMock.shopperBaskets.getBasket.mockRejectedValue(new Error('not ready')) - createBasket.mutateAsync.mockResolvedValue({basketId: 'new-1'}) - - const preLoginItems = [ - {productId: 'sku-1', quantity: 2, variationAttributes: [], optionItems: []} - ] - const shipmentSnapshot = { - shippingAddress: { - address1: '5 Wall St', - city: 'Burlington', - countryCode: 'US', - firstName: 'S', - lastName: 'Y', - phone: '555-555-5555', - postalCode: '01803', - stateCode: 'MA' - }, - shippingMethod: {id: 'Ground'} - } - - const {result} = renderHook(() => useBasketRecovery()) - await act(async () => { - await result.current.recoverBasketAfterAuth({ - preLoginItems, - shipment: shipmentSnapshot, - doMerge: true - }) - }) - - expect(createBasket.mutateAsync).toHaveBeenCalled() - expect(addItemToBasket.mutateAsync).toHaveBeenCalledWith({ - parameters: {basketId: 'new-1'}, - body: [expect.objectContaining({productId: 'sku-1', quantity: 2})] - }) - expect(updateShippingAddressForShipment.mutateAsync).toHaveBeenCalledWith({ - parameters: {basketId: 'new-1', shipmentId: 'me'}, - body: expect.objectContaining({address1: '5 Wall St'}) - }) - expect(updateShippingMethodForShipment.mutateAsync).toHaveBeenCalledWith({ - parameters: {basketId: 'new-1', shipmentId: 'me'}, - body: {id: 'Ground'} - }) - // Invalidate may be elided in test env; existence is sufficient here - expect(typeof mockInvalidate).toBe('function') - }) - - test('does not add items when preLoginItems is empty', async () => { - mergeBasket.mutateAsync.mockResolvedValue({basketId: 'dest-1'}) - apiMock.shopperBaskets.getBasket.mockResolvedValue({ - basketId: 'dest-1', - shipments: [{shipmentId: 'me'}] - }) - - const shipmentSnapshot = { - shippingAddress: { - address1: 'a', - city: 'b', - countryCode: 'US', - firstName: 'x', - lastName: 'y', - phone: '1', - postalCode: 'z', - stateCode: 'MA' - } - } - - const {result} = renderHook(() => useBasketRecovery()) - await act(async () => { - await result.current.recoverBasketAfterAuth({ - preLoginItems: [], - shipment: shipmentSnapshot, - doMerge: true - }) - }) - - expect(addItemToBasket.mutateAsync).not.toHaveBeenCalled() - expect(updateShippingAddressForShipment.mutateAsync).toHaveBeenCalled() - // In some environments invalidate may be coalesced; just ensure the client exists - expect(typeof mockInvalidate).toBe('function') - }) - - test('guest flow snapshotted shipping is re-applied after OTP merge', async () => { - // Simulate guest checkout snapshot with items and shipping - const preLoginItems = [{productId: 'sku-otp', quantity: 1}] - const shipmentSnapshot = { - shippingAddress: { - address1: 'Guest St', - city: 'OTP City', - countryCode: 'US', - firstName: 'Guest', - lastName: 'User', - phone: '111-222-3333', - postalCode: '99999', - stateCode: 'NY' - }, - shippingMethod: {id: 'Express'} - } - - // Merge succeeds but hydrate returns shipments with a concrete id - mergeBasket.mutateAsync.mockResolvedValue({basketId: 'dest-otp'}) - apiMock.shopperBaskets.getBasket.mockResolvedValue({ - basketId: 'dest-otp', - shipments: [{shipmentId: 'shp-otp'}] - }) - - const {result} = renderHook(() => useBasketRecovery()) - await act(async () => { - await result.current.recoverBasketAfterAuth({ - preLoginItems, - shipment: shipmentSnapshot, - doMerge: true - }) - }) - - // We expect no item copy when merge completed and hydration worked, - // but we do expect shipping to be re-applied using the hydrated shipment id. - expect(addItemToBasket.mutateAsync).not.toHaveBeenCalled() - expect(updateShippingAddressForShipment.mutateAsync).toHaveBeenCalledWith({ - parameters: {basketId: 'dest-otp', shipmentId: 'shp-otp'}, - body: expect.objectContaining({address1: 'Guest St'}) - }) - expect(updateShippingMethodForShipment.mutateAsync).toHaveBeenCalledWith({ - parameters: {basketId: 'dest-otp', shipmentId: 'shp-otp'}, - body: {id: 'Express'} - }) - }) -}) diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx index a34a634971..28d4725f54 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-payment.jsx @@ -330,7 +330,8 @@ const Payment = ({ }) await currentBasketQuery.refetch() } - } catch { + } catch (_e) { + // Fail silently } } setIsApplyingSavedPayment(false) diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.jsx index 1a72d42103..1edc93e24f 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.jsx @@ -21,7 +21,6 @@ import {useCustomerType, useAuthHelper, AuthHelpers} from '@salesforce/commerce- import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin' import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils' -import useBasketRecovery from '@salesforce/retail-react-app/app/hooks/use-basket-recovery' export default function UserRegistration({ enableUserRegistration, @@ -35,7 +34,6 @@ export default function UserRegistration({ const {isGuest} = useCustomerType() const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser) - const {recoverBasketAfterAuth} = useBasketRecovery() const appOrigin = useAppOrigin() const passwordlessConfigCallback = getConfig().app.login?.passwordless?.callbackURI const callbackURL = isAbsoluteURL(passwordlessConfigCallback) @@ -43,6 +41,7 @@ export default function UserRegistration({ : `${appOrigin}${passwordlessConfigCallback}` const {isOpen: isOtpOpen, onOpen: onOtpOpen, onClose: onOtpClose} = useDisclosure() const otpSentRef = useRef(false) + const handleUserRegistrationChange = async (e) => { const checked = e.target.checked setEnableUserRegistration(checked) @@ -66,6 +65,23 @@ export default function UserRegistration({ } } + const handleOtpVerification = async (otpCode) => { + try { + await loginPasswordless.mutateAsync({ + pwdlessLoginToken: otpCode, + register_customer: true + }) + + if (onRegistered) { + await onRegistered(basket?.basketId) + } + onOtpClose() + } catch (_e) { + // Let OtpAuth surface errors via its own UI/toast + } + return {success: true} + } + // Hide the form if the "Checkout as Guest" button was clicked if (isGuestCheckout) { return null @@ -133,26 +149,7 @@ export default function UserRegistration({ email }) }} - handleOtpVerification={async (otpCode) => { - try { - await loginPasswordless.mutateAsync({ - pwdlessLoginToken: otpCode, - register_customer: true - }) - const newBasketId = await recoverBasketAfterAuth({ - preLoginItems: basket?.productItems || [], - shipment: basket?.shipments?.[0] || null, - doMerge: true - }) - if (onRegistered) { - await onRegistered(newBasketId) - } - onOtpClose() - } catch (_e) { - // Let OtpAuth surface errors via its own UI/toast - } - return {success: true} - }} + handleOtpVerification={handleOtpVerification} /> ) diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.test.js index eea193efa8..ccbed06baf 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-user-registration.test.js @@ -14,12 +14,20 @@ import {useCustomerType, useAuthHelper} from '@salesforce/commerce-sdk-react' import useAuthContext from '@salesforce/commerce-sdk-react/hooks/useAuthContext' jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket') + +const {AuthHelpers} = jest.requireActual('@salesforce/commerce-sdk-react') + +const mockAuthHelperFunctions = { + [AuthHelpers.AuthorizePasswordless]: {mutateAsync: jest.fn()}, + [AuthHelpers.LoginPasswordlessUser]: {mutateAsync: jest.fn()} +} + jest.mock('@salesforce/commerce-sdk-react', () => { const original = jest.requireActual('@salesforce/commerce-sdk-react') return { ...original, useCustomerType: jest.fn(), - useAuthHelper: jest.fn() + useAuthHelper: jest.fn((helper) => mockAuthHelperFunctions[helper]) } }) jest.mock('@salesforce/commerce-sdk-react/hooks/useAuthContext', () => @@ -47,31 +55,33 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-app-origin', () => ({ jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ getConfig: () => ({app: {login: {passwordless: {callbackURI: '/callback'}}}}) })) -jest.mock('@salesforce/retail-react-app/app/hooks/use-basket-recovery', () => () => ({ - recoverBasketAfterAuth: jest.fn(async () => 'basket-new-123') -})) const setup = (overrides = {}) => { const defaultBasket = { + basketId: 'basket-123', customerInfo: {email: 'test@example.com'}, productItems: [{productId: 'sku-1', quantity: 1}], shipments: [{shippingAddress: {address1: '123 Main'}, shippingMethod: {id: 'Ground'}}] } + useCurrentBasket.mockReturnValue({data: overrides.basket ?? defaultBasket}) useCustomerType.mockReturnValue({isGuest: overrides.isGuest ?? true}) useAuthContext.mockReturnValue({refreshAccessToken: jest.fn().mockResolvedValue(undefined)}) - const authorizePasswordlessLogin = {mutateAsync: jest.fn().mockResolvedValue({})} - const loginPasswordless = {mutateAsync: jest.fn().mockResolvedValue({})} - useAuthHelper.mockImplementation((helper) => { - if (helper && helper.name && /AuthorizePasswordless/i.test(helper.name)) { - return authorizePasswordlessLogin - } - if (helper && helper.name && /LoginPasswordlessUser/i.test(helper.name)) { - return loginPasswordless - } - return {mutateAsync: jest.fn()} - }) + // Set up specific mock behaviors if provided via overrides + if (overrides.authorizeMutate) { + mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync = + overrides.authorizeMutate + } else { + mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockResolvedValue({}) + } + + if (overrides.loginMutate) { + mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync = + overrides.loginMutate + } else { + mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync.mockResolvedValue({}) + } const props = { enableUserRegistration: overrides.enable ?? false, @@ -87,7 +97,12 @@ const setup = (overrides = {}) => { ) - return {utils, props, authorizePasswordlessLogin, loginPasswordless} + return { + utils, + props, + authorizePasswordlessLogin: mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless], + loginPasswordless: mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser] + } } describe('UserRegistration', () => { @@ -97,18 +112,28 @@ describe('UserRegistration', () => { test('opt-in triggers save preference and opens OTP for guest', async () => { const user = userEvent.setup() - const {props} = setup() + const {props, authorizePasswordlessLogin} = setup() // Toggle on await user.click(screen.getByRole('checkbox', {name: /Create an account/i})) expect(props.setEnableUserRegistration).toHaveBeenCalledWith(true) expect(props.onSavePreferenceChange).toHaveBeenCalledWith(true) + // Verify authorize passwordless was called + await waitFor(() => { + expect(authorizePasswordlessLogin.mutateAsync).toHaveBeenCalledWith({ + userid: 'test@example.com', + callbackURI: 'http://localhost:3000/callback?mode=otp_email', + register_customer: true, + last_name: 'test@example.com', + email: 'test@example.com' + }) + }) // Guest registration OTP modal should render with guest flag expect(await screen.findByTestId('otp-guest')).toBeInTheDocument() // Modal appears (mocked), verify OTP triggers onRegistered callback const otpButton = await screen.findByTestId('otp-verify') await user.click(otpButton) await waitFor(() => { - expect(props.onRegistered).toHaveBeenCalledWith('basket-new-123') + expect(props.onRegistered).toHaveBeenCalledWith('basket-123') }) }) @@ -128,5 +153,128 @@ describe('UserRegistration', () => { await user.click(cb) // off expect(props.onSavePreferenceChange).toHaveBeenCalledWith(false) }) + + test('hides component when isGuestCheckout is true', () => { + setup({isGuestCheckout: true}) + expect(screen.queryByTestId('sf-user-registration-content')).not.toBeInTheDocument() + }) + + test('renders component when isGuestCheckout is false', () => { + setup({isGuestCheckout: false}) + expect(screen.getByTestId('sf-user-registration-content')).toBeInTheDocument() + }) + + test('disables checkbox when isDisabled is true', () => { + setup({isDisabled: true}) + const checkbox = screen.getByRole('checkbox', {name: /Create an account/i}) + expect(checkbox).toBeDisabled() + }) + + test('does not send OTP when basket has no email', async () => { + const user = userEvent.setup() + const basketWithoutEmail = { + basketId: 'basket-123', + customerInfo: {}, + productItems: [{productId: 'sku-1', quantity: 1}] + } + const {authorizePasswordlessLogin} = setup({basket: basketWithoutEmail}) + await user.click(screen.getByRole('checkbox', {name: /Create an account/i})) + expect(authorizePasswordlessLogin.mutateAsync).not.toHaveBeenCalled() + }) + + test('does not send OTP when basket customerInfo is undefined', async () => { + const user = userEvent.setup() + const basketWithoutCustomerInfo = { + basketId: 'basket-123', + productItems: [{productId: 'sku-1', quantity: 1}] + } + const {authorizePasswordlessLogin} = setup({basket: basketWithoutCustomerInfo}) + await user.click(screen.getByRole('checkbox', {name: /Create an account/i})) + expect(authorizePasswordlessLogin.mutateAsync).not.toHaveBeenCalled() + }) + + test('handles authorize passwordless error gracefully', async () => { + const user = userEvent.setup() + const authorizeMutate = jest.fn().mockRejectedValue(new Error('Network error')) + const {props} = setup({authorizeMutate}) + await user.click(screen.getByRole('checkbox', {name: /Create an account/i})) + expect(props.setEnableUserRegistration).toHaveBeenCalledWith(true) + // Should not throw error, component continues to work + expect(screen.getByRole('checkbox', {name: /Create an account/i})).toBeInTheDocument() + }) + + test('prevents duplicate OTP sends', async () => { + const user = userEvent.setup() + const {authorizePasswordlessLogin} = setup() + const checkbox = screen.getByRole('checkbox', {name: /Create an account/i}) + // Click to enable + await user.click(checkbox) + await waitFor(() => { + expect(authorizePasswordlessLogin.mutateAsync).toHaveBeenCalledTimes(1) + }) + // Click to disable + await user.click(checkbox) + // Click to enable again + await user.click(checkbox) + // Should still only have been called once due to otpSentRef + expect(authorizePasswordlessLogin.mutateAsync).toHaveBeenCalledTimes(1) + }) + + test('OTP resend functionality works', async () => { + const user = userEvent.setup() + const {authorizePasswordlessLogin} = setup() + await user.click(screen.getByRole('checkbox', {name: /Create an account/i})) + await waitFor(() => { + expect(screen.getByTestId('otp-guest')).toBeInTheDocument() + }) + // Initial authorize call + expect(authorizePasswordlessLogin.mutateAsync).toHaveBeenCalledTimes(1) + }) + + test('calls loginPasswordless with OTP code and register flag', async () => { + const user = userEvent.setup() + const {loginPasswordless} = setup() + await user.click(screen.getByRole('checkbox', {name: /Create an account/i})) + const otpButton = await screen.findByTestId('otp-verify') + await user.click(otpButton) + await waitFor(() => { + expect(loginPasswordless.mutateAsync).toHaveBeenCalledWith({ + pwdlessLoginToken: 'otp-123', + register_customer: true + }) + }) + }) + + test('handles OTP verification error gracefully', async () => { + const user = userEvent.setup() + const loginMutate = jest.fn().mockRejectedValue(new Error('Invalid OTP')) + const {props} = setup({loginMutate}) + await user.click(screen.getByRole('checkbox', {name: /Create an account/i})) + const otpButton = await screen.findByTestId('otp-verify') + await user.click(otpButton) + // Wait for async operations + await waitFor(() => { + expect(loginMutate).toHaveBeenCalled() + }) + // onRegistered should not be called on error + expect(props.onRegistered).not.toHaveBeenCalled() + }) + + test('displays explanatory text when registration is enabled', () => { + // Test with registration disabled + const {utils} = setup({enable: false}) + expect( + screen.queryByText(/Your payment, address, and contact information/i) + ).not.toBeInTheDocument() + + // Clean up first render + utils.unmount() + + // Test with registration enabled + setup({enable: true}) + expect( + screen.getByText(/Your payment, address, and contact information/i) + ).toBeInTheDocument() + }) }) // end diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index 3b89fbdc35..ab15e940f2 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -107,6 +107,12 @@ "value": "Add a new payment method to check out faster." } ], + "account.title.my_account": [ + { + "type": 0, + "value": "My Account" + } + ], "account_addresses.badge.default": [ { "type": 0, @@ -937,6 +943,12 @@ "value": "Save this payment method for future use" } ], + "checkout.title.checkout": [ + { + "type": 0, + "value": "Checkout" + } + ], "checkout.title.user_registration": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index 3b89fbdc35..ab15e940f2 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -107,6 +107,12 @@ "value": "Add a new payment method to check out faster." } ], + "account.title.my_account": [ + { + "type": 0, + "value": "My Account" + } + ], "account_addresses.badge.default": [ { "type": 0, @@ -937,6 +943,12 @@ "value": "Save this payment method for future use" } ], + "checkout.title.checkout": [ + { + "type": 0, + "value": "Checkout" + } + ], "checkout.title.user_registration": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index ca4b445790..106132e49c 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -251,6 +251,20 @@ "value": "]" } ], + "account.title.my_account": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ḿẏ Ȧƈƈǿǿŭŭƞŧ" + }, + { + "type": 0, + "value": "]" + } + ], "account_addresses.badge.default": [ { "type": 0, @@ -1841,6 +1855,20 @@ "value": "]" } ], + "checkout.title.checkout": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƈħḗḗƈķǿǿŭŭŧ" + }, + { + "type": 0, + "value": "]" + } + ], "checkout.title.user_registration": [ { "type": 0, diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index 7a82a0758f..c9a125292a 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -53,6 +53,9 @@ "account.payments.placeholder.text": { "defaultMessage": "Add a new payment method to check out faster." }, + "account.title.my_account": { + "defaultMessage": "My Account" + }, "account_addresses.badge.default": { "defaultMessage": "Default" }, @@ -339,6 +342,9 @@ "checkout.payment.save_payment_method": { "defaultMessage": "Save this payment method for future use" }, + "checkout.title.checkout": { + "defaultMessage": "Checkout" + }, "checkout.title.user_registration": { "defaultMessage": "Save Checkout Info for Future Use" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index 7a82a0758f..c9a125292a 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -53,6 +53,9 @@ "account.payments.placeholder.text": { "defaultMessage": "Add a new payment method to check out faster." }, + "account.title.my_account": { + "defaultMessage": "My Account" + }, "account_addresses.badge.default": { "defaultMessage": "Default" }, @@ -339,6 +342,9 @@ "checkout.payment.save_payment_method": { "defaultMessage": "Save this payment method for future use" }, + "checkout.title.checkout": { + "defaultMessage": "Checkout" + }, "checkout.title.user_registration": { "defaultMessage": "Save Checkout Info for Future Use" },