From ce16e61e7f1f27193ff897e8c9037cd2a44201ca Mon Sep 17 00:00:00 2001 From: Ujwala <59447810+unandyala@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:36:51 -0600 Subject: [PATCH 01/11] @W-21066774: Add config flag to enable/disable HttpOnly session cookies (#3635) * Add config flag to enable httponly session cookies --- packages/pwa-kit-create-app/CHANGELOG.md | 1 + .../assets/bootstrap/js/config/default.js.hbs | 2 ++ .../@salesforce/retail-react-app/config/default.js.hbs | 2 ++ packages/pwa-kit-dev/CHANGELOG.md | 2 ++ packages/pwa-kit-dev/bin/pwa-kit-dev.js | 9 +++++++-- packages/pwa-kit-react-sdk/CHANGELOG.md | 2 ++ .../pwa-kit-react-sdk/src/ssr/server/react-rendering.js | 1 + packages/template-retail-react-app/CHANGELOG.md | 1 + packages/template-retail-react-app/config/default.js | 2 ++ 9 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/pwa-kit-create-app/CHANGELOG.md b/packages/pwa-kit-create-app/CHANGELOG.md index 7c9187eb72..c24080977e 100644 --- a/packages/pwa-kit-create-app/CHANGELOG.md +++ b/packages/pwa-kit-create-app/CHANGELOG.md @@ -3,6 +3,7 @@ - Support email mode by default for passwordless login and password reset in a generated app. [#3525](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3525) - Util function for passwordless callback URI [#3630](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3630) - Add `tokenLength` to login configuration [#3554](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3554) +- Add configuration flag `disableHttpOnlySessionCookies` to `ssrParameters` [#3635](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3635) ## v3.15.0 (Dec 17, 2025) - Add new Google Cloud API configuration and Bonus Product configuration [#3523](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3523) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index d76b6c4c88..7815a37734 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -182,6 +182,8 @@ module.exports = { // Additional parameters that configure Express app behavior. ssrParameters: { ssrFunctionNodeVersion: '22.x', + // Store the session cookies as HttpOnly for enhanced security. + disableHttpOnlySessionCookies: false, proxyConfigs: [ { host: '{{answers.project.commerce.shortCode}}.api.commercecloud.salesforce.com', diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index e4c13b3d41..70eeeb78c4 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -178,6 +178,8 @@ module.exports = { // Additional parameters that configure Express app behavior. ssrParameters: { ssrFunctionNodeVersion: '22.x', + // Store the session cookies as HttpOnly for enhanced security. + disableHttpOnlySessionCookies: false, proxyConfigs: [ { host: '{{answers.project.commerce.shortCode}}.api.commercecloud.salesforce.com', diff --git a/packages/pwa-kit-dev/CHANGELOG.md b/packages/pwa-kit-dev/CHANGELOG.md index d765f05c93..29004508e0 100644 --- a/packages/pwa-kit-dev/CHANGELOG.md +++ b/packages/pwa-kit-dev/CHANGELOG.md @@ -1,4 +1,6 @@ ## v3.16.0-dev (Dec 17, 2025) +Add configuration flag `disableHttpOnlySessionCookies` to `ssrParameters` [#3635](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3635) + ## v3.15.0 (Dec 17, 2025) ## v3.14.0 (Nov 04, 2025) diff --git a/packages/pwa-kit-dev/bin/pwa-kit-dev.js b/packages/pwa-kit-dev/bin/pwa-kit-dev.js index e07e4e30b7..3c77d8a620 100755 --- a/packages/pwa-kit-dev/bin/pwa-kit-dev.js +++ b/packages/pwa-kit-dev/bin/pwa-kit-dev.js @@ -253,11 +253,16 @@ const main = async () => { error('Could not determine app entrypoint.') process.exit(1) } - + // Load config to get envBasePath and disableHttpOnlySessionCookies from ssrParameters for local development + // This mimics how MRT sets the system environment variable + const config = getConfig() || {} + const disableHttpOnlySessionCookies = + config.ssrParameters?.disableHttpOnlySessionCookies || true execSync(`${babelNode} ${inspect ? '--inspect' : ''} ${babelArgs} ${entrypoint}`, { env: { ...process.env, - ...(noHMR ? {HMR: 'false'} : {}) + ...(noHMR ? {HMR: 'false'} : {}), + MRT_DISABLE_HTTPONLY_SESSION_COOKIES: disableHttpOnlySessionCookies } }) }) diff --git a/packages/pwa-kit-react-sdk/CHANGELOG.md b/packages/pwa-kit-react-sdk/CHANGELOG.md index 33cf9fa905..c27e0e31b4 100644 --- a/packages/pwa-kit-react-sdk/CHANGELOG.md +++ b/packages/pwa-kit-react-sdk/CHANGELOG.md @@ -1,4 +1,6 @@ ## v3.16.0-dev (Dec 17, 2025) +Add configuration flag `disableHttpOnlySessionCookies` to `ssrParameters` [#3635](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3635) + ## v3.15.0 (Dec 17, 2025) ## v3.14.0 (Nov 04, 2025) diff --git a/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.js b/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.js index ed745a8ea4..c57bed655f 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.js +++ b/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.js @@ -365,6 +365,7 @@ const renderApp = (args) => { __CONFIG__: config, __PRELOADED_STATE__: appState, __ERROR__: error, + __MRT_DISABLE_HTTPONLY_SESSION_COOKIES__: process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES, // `window.Progressive` has a long history at Mobify and some // client-side code depends on it. Maintain its name out of tradition. Progressive: getWindowProgressive(req, res) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index b446d6905d..d9a4f55b41 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -12,6 +12,7 @@ - Util function for passwordless callback URI [#3630](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3630) - [BREAKING] Remove unused absoluteUrl util from retail react app [#3633](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3633) - Allow shopper to manually input OTP during passwordless login [#3554](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3554) +- Add configuration flag `disableHttpOnlySessionCookies` to `ssrParameters` [#3635](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3635) ## v8.3.0 (Dec 17, 2025) - [Bugfix] Fix Forgot Password link not working from Account Profile password update form [#3493](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3493) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index a00e2bb735..3ae071c644 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -103,6 +103,8 @@ module.exports = { ], ssrParameters: { ssrFunctionNodeVersion: '22.x', + // Store the session cookies as HttpOnly for enhanced security. + disableHttpOnlySessionCookies: false, proxyConfigs: [ { host: 'kv7kzm78.api.commercecloud.salesforce.com', From d0936278fcf3df989ec8e6918acba1240d2bc221 Mon Sep 17 00:00:00 2001 From: Danny Phan <125327707+dannyphan2000@users.noreply.github.com> Date: Fri, 6 Feb 2026 03:02:49 +0700 Subject: [PATCH 02/11] @W-21094171: Fix Resend Code for OTP modal (#3624) * One click checkout * changes to fix install, tests and lint - needs to be reviewed * revert the test change in pwa-kit-runtime * @W-20892497 Show Phone number in Contact Info summary (#3576) * W-20892497 Show Phone number in Contact Info summary * fix lint * @W-20892592 Remove gift messaging for multi shipment (#3579) * W-20892592 Remove gift messaging for multi shipment * translations * @W-20892530 @W-20892577 Billing Address Validation and Using contact phone for user registration (#3583) * W-20892530 Billing Address Validation * W-20892577 save contact info phone * Fix SDK tests (#3593) * fix sdk tests and app bundle size * fix lint * @ W-20540715 Address 1CC feature branch review comments (#3619) * address first set of comments * address rest of code review comments * reverting default.js changes * fix package versions * shipping options fix * attempt to fix flaky tests * passwordless mode updates * @W-21094171: Fix Resend Code for OTP modal Signed-off-by: d.phan * add translations Signed-off-by: d.phan * fix lint Signed-off-by: d.phan * update component per UX alignment Signed-off-by: d.phan * no actions during verification Signed-off-by: d.phan * revert to generic error message as per code review comment * transaltion changes * fix the rebase issue * update isomorphic version * update isomorhic in dev dependencies * Updating another dev dependency for isomorphic version * @W-21094171: Fix Resend Code for OTP modal Signed-off-by: d.phan * fix package versions --------- Signed-off-by: d.phan Co-authored-by: Sushma Yadupathi Co-authored-by: syadupathi-sf <66088780+syadupathi-sf@users.noreply.github.com> --- packages/commerce-sdk-react/package-lock.json | 8 +- packages/commerce-sdk-react/package.json | 2 +- .../app/components/otp-auth/index.jsx | 219 +++++++++--------- .../app/components/otp-auth/index.test.js | 106 +++++++-- .../partials/one-click-contact-info.jsx | 10 +- .../static/translations/compiled/en-GB.json | 54 +++-- .../static/translations/compiled/en-US.json | 54 +++-- .../static/translations/compiled/en-XA.json | 62 +++-- .../translations/en-GB.json | 7 +- .../translations/en-US.json | 7 +- 10 files changed, 328 insertions(+), 201 deletions(-) diff --git a/packages/commerce-sdk-react/package-lock.json b/packages/commerce-sdk-react/package-lock.json index 0ef1d77a0f..4ddeb1704e 100644 --- a/packages/commerce-sdk-react/package-lock.json +++ b/packages/commerce-sdk-react/package-lock.json @@ -9,7 +9,7 @@ "version": "5.0.0-dev", "license": "See license in LICENSE", "dependencies": { - "commerce-sdk-isomorphic": "5.0.0-preview.1", + "commerce-sdk-isomorphic": "5.0.0", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, @@ -920,9 +920,9 @@ "license": "MIT" }, "node_modules/commerce-sdk-isomorphic": { - "version": "5.0.0-preview.1", - "resolved": "https://registry.npmjs.org/commerce-sdk-isomorphic/-/commerce-sdk-isomorphic-5.0.0-preview.1.tgz", - "integrity": "sha512-VsGHgj+OCorouoE6b3+JL/qw6xiBnrspk0DAeyNdIttMVNpdXLKEuXjbruw4LC7F0C9Da7GloSR/NiNMYcE+Bg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/commerce-sdk-isomorphic/-/commerce-sdk-isomorphic-5.0.0.tgz", + "integrity": "sha512-9E0wEKq3pBoAdmjLByBdDVfcNbmo+G61WdxpMpHKtRzIlzbVhjLTNIrxxzRGP+267iTCbJg9sgB8SJLjG2hxTg==", "license": "BSD-3-Clause", "dependencies": { "nanoid": "^3.3.8", diff --git a/packages/commerce-sdk-react/package.json b/packages/commerce-sdk-react/package.json index d6a4fb68b2..d090f2bee1 100644 --- a/packages/commerce-sdk-react/package.json +++ b/packages/commerce-sdk-react/package.json @@ -40,7 +40,7 @@ "version": "node ./scripts/version.js" }, "dependencies": { - "commerce-sdk-isomorphic": "5.0.0-preview.1", + "commerce-sdk-isomorphic": "5.0.0", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, diff --git a/packages/template-retail-react-app/app/components/otp-auth/index.jsx b/packages/template-retail-react-app/app/components/otp-auth/index.jsx index ed38132b6f..886c83ce52 100644 --- a/packages/template-retail-react-app/app/components/otp-auth/index.jsx +++ b/packages/template-retail-react-app/app/components/otp-auth/index.jsx @@ -37,7 +37,8 @@ const OtpAuth = ({ handleOtpVerification, onCheckoutAsGuest, isGuestRegistration = false, - hideCheckoutAsGuestButton = false + hideCheckoutAsGuestButton = false, + resendCooldownDuration = 30 }) => { const {tokenLength} = getConfig().app.login const parsedLength = Number(tokenLength) @@ -99,6 +100,8 @@ const OtpAuth = ({ otpInputs.clear() setError('') form.setValue('otp', '') + // Start resend cooldown when modal opens + setResendTimer(resendCooldownDuration) // Track OTP modal view activity track('/otp-authentication', { @@ -108,10 +111,10 @@ const OtpAuth = ({ setTimeout(() => otpInputs.inputRefs.current[0]?.focus(), 100) } - }, [isOpen]) + }, [isOpen, resendCooldownDuration]) const handleVerify = async (code = otpInputs.values.join('')) => { - if (code.length !== OTP_LENGTH) return + if (isVerifying || code.length !== OTP_LENGTH) return setIsVerifying(true) setError('') @@ -147,14 +150,17 @@ const OtpAuth = ({ } const handleResend = async () => { - setResendTimer(5) + // No action while verifying or during cooldown; button stays visible/enabled + if (isVerifying || resendTimer > 0) return + + setResendTimer(resendCooldownDuration) try { await track('/otp-resend', { activity: 'otp_code_resent', context: 'authentication', resendAttempt: true }) - await handleSendEmailOtp(form.getValues('email')) + await handleSendEmailOtp(form.getValues('email'), true) } catch (error) { setResendTimer(0) await track('/otp-resend-failed', { @@ -167,6 +173,8 @@ const OtpAuth = ({ } const handleCheckoutAsGuest = async () => { + if (isVerifying) return + // Track checkout as guest selection await track('/checkout-as-guest', { activity: 'checkout_as_guest_selected', @@ -191,8 +199,6 @@ const OtpAuth = ({ } } - const isResendDisabled = resendTimer > 0 || isVerifying - return ( @@ -213,7 +219,7 @@ const OtpAuth = ({ - + {isGuestRegistration ? ( - {/* OTP Input */} - - {Array.from({length: OTP_LENGTH}).map((_, index) => ( - (otpInputs.inputRefs.current[index] = el)} - value={otpInputs.values[index]} - onChange={(e) => handleInputChange(index, e.target.value)} - onKeyDown={(e) => otpInputs.handleKeyDown(index, e)} - onPaste={otpInputs.handlePaste} - type="text" - inputMode="numeric" - maxLength={1} - textAlign="center" - fontSize="lg" - fontWeight="bold" - size="lg" - width="48px" - height="56px" - borderRadius="md" - borderColor="gray.300" - borderWidth="2px" - disabled={isVerifying} - _focus={{ - borderColor: 'blue.500', - boxShadow: '0 0 0 1px var(--chakra-colors-blue-500)' - }} - _hover={{ - borderColor: 'gray.400' - }} - /> - ))} - + + {/* OTP Input */} + + {Array.from({length: OTP_LENGTH}).map((_, index) => ( + (otpInputs.inputRefs.current[index] = el)} + value={otpInputs.values[index]} + onChange={(e) => handleInputChange(index, e.target.value)} + onKeyDown={(e) => otpInputs.handleKeyDown(index, e)} + onPaste={otpInputs.handlePaste} + type="text" + inputMode="numeric" + maxLength={1} + textAlign="center" + fontSize="lg" + fontWeight="bold" + size="lg" + width={12} + height={14} + borderRadius="md" + borderColor={error ? 'red.500' : 'gray.300'} + borderWidth={2} + disabled={isVerifying} + _focus={{ + borderColor: error ? 'red.500' : 'blue.500', + boxShadow: error + ? '0 0 0 1px var(--chakra-colors-red-500)' + : '0 0 0 1px var(--chakra-colors-blue-500)' + }} + _hover={{ + borderColor: error ? 'red.500' : 'gray.400' + }} + /> + ))} + - {/* Loading indicator during verification */} - {isVerifying && ( - - - - )} + {/* Error message */} + {error && ( + + {error} + + )} - {/* Error message */} - {error && ( - - {error} - - )} + {/* Countdown message */} + {resendTimer > 0 && ( + + + + )} + + {/* Buttons */} + + {!hideCheckoutAsGuestButton && ( + + )} - {/* Buttons */} - - {!hideCheckoutAsGuestButton && ( - )} - - - + + + @@ -353,7 +354,9 @@ OtpAuth.propTypes = { handleOtpVerification: PropTypes.func.isRequired, onCheckoutAsGuest: PropTypes.func, isGuestRegistration: PropTypes.bool, - hideCheckoutAsGuestButton: PropTypes.bool + hideCheckoutAsGuestButton: PropTypes.bool, + /** Resend cooldown (in seconds). Default 30. */ + resendCooldownDuration: PropTypes.number } export default OtpAuth diff --git a/packages/template-retail-react-app/app/components/otp-auth/index.test.js b/packages/template-retail-react-app/app/components/otp-auth/index.test.js index dd5054aa0b..06e2a2d049 100644 --- a/packages/template-retail-react-app/app/components/otp-auth/index.test.js +++ b/packages/template-retail-react-app/app/components/otp-auth/index.test.js @@ -102,7 +102,7 @@ describe('OtpAuth', () => { describe('Component Rendering', () => { test('renders OTP form with all elements', () => { - renderWithProviders() + renderWithProviders() expect(screen.getByText("Confirm it's you")).toBeInTheDocument() expect( @@ -127,7 +127,7 @@ describe('OtpAuth', () => { }) test('renders buttons with correct styling', () => { - renderWithProviders() + renderWithProviders() const guestButton = screen.getByRole('button', {name: /Checkout as a Guest/i}) const resendButton = screen.getByRole('button', {name: /Resend Code/i}) @@ -464,16 +464,51 @@ describe('OtpAuth', () => { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtp} + resendCooldownDuration={0} /> ) const resendButton = screen.getByText(/Resend code/i) await user.click(resendButton) - expect(mockHandleSendEmailOtp).toHaveBeenCalledWith('test@example.com') + expect(mockHandleSendEmailOtp).toHaveBeenCalledWith('test@example.com', true) + }) + + test('countdown message is shown on initial open and both buttons are always visible', () => { + renderWithProviders( + + ) + + // FormattedMessage may render "30 second(s)." (ICU plural) + expect(screen.getByText(/You can request a new code in 30 second/i)).toBeInTheDocument() + expect(screen.getByRole('button', {name: /Resend Code/i})).toBeInTheDocument() + expect(screen.getByRole('button', {name: /Checkout as a Guest/i})).toBeInTheDocument() + }) + + test('countdown message uses custom cooldown duration when provided', () => { + renderWithProviders( + + ) + + // FormattedMessage may render "15 second(s)." (ICU plural) + expect(screen.getByText(/You can request a new code in 15 second/i)).toBeInTheDocument() + expect(screen.getByRole('button', {name: /Resend Code/i})).toBeInTheDocument() }) - test('resend button is disabled during countdown', async () => { + test('clicking Resend during cooldown does not send OTP', async () => { const user = userEvent.setup() renderWithProviders( { /> ) - // Click the resend button - await user.click(screen.getByRole('button', {name: /Resend Code/i})) + // Resend button is always visible; during initial 30s cooldown it should not send + const resendButton = screen.getByRole('button', {name: /Resend Code/i}) + await user.click(resendButton) + await user.click(resendButton) - // Wait for the timer text to appear and assert the parent button is disabled - const timerText = await screen.findByText(/Resend code in/i) - const disabledResendButton = timerText.closest('button') - expect(disabledResendButton).toBeDisabled() + expect(mockHandleSendEmailOtp).not.toHaveBeenCalled() }) - test('resend button becomes enabled after countdown', async () => { + test('clicking Resend when cooldown is complete sends OTP and shows countdown', async () => { const user = userEvent.setup() renderWithProviders( { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtp} + resendCooldownDuration={0} /> ) - const resendButton = screen.getByText(/Resend code/i) + const resendButton = screen.getByRole('button', {name: /Resend Code/i}) await user.click(resendButton) - // Wait for countdown to complete (mocked timers would be ideal here) - await waitFor(() => { - expect(resendButton).toBeDisabled() - }) + expect(mockHandleSendEmailOtp).toHaveBeenCalledWith('test@example.com', true) + }) + + test('clicking Resend again during cooldown after first send does not send again', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + // Wait for initial 2s cooldown to expire (text is "X second(s)." from FormattedMessage) + await waitFor( + () => { + expect( + screen.queryByText(/You can request a new code in \d+ second/i) + ).not.toBeInTheDocument() + }, + {timeout: 3000} + ) + + await user.click(screen.getByRole('button', {name: /Resend Code/i})) + expect(mockHandleSendEmailOtp).toHaveBeenCalledTimes(1) + + // Countdown is showing; click Resend again - should not send + expect( + screen.getByText(/You can request a new code in \d+ second/i) + ).toBeInTheDocument() + await user.click(screen.getByRole('button', {name: /Resend Code/i})) + + expect(mockHandleSendEmailOtp).toHaveBeenCalledTimes(1) }) }) @@ -530,6 +597,7 @@ describe('OtpAuth', () => { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtpError} + resendCooldownDuration={0} /> ) @@ -555,7 +623,7 @@ describe('OtpAuth', () => { }) test('buttons have accessible text', () => { - renderWithProviders() + renderWithProviders() expect(screen.getByRole('button', {name: /Checkout as a Guest/i})).toBeInTheDocument() expect(screen.getByRole('button', {name: /Resend Code/i})).toBeInTheDocument() @@ -783,6 +851,7 @@ describe('OtpAuth', () => { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtp} + resendCooldownDuration={0} /> ) @@ -812,6 +881,7 @@ describe('OtpAuth', () => { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtp} + resendCooldownDuration={0} /> ) @@ -920,6 +990,7 @@ describe('OtpAuth', () => { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtp} + resendCooldownDuration={0} /> ) @@ -989,6 +1060,7 @@ describe('OtpAuth', () => { form={mockForm} handleOtpVerification={mockHandleOtpVerification} handleSendEmailOtp={mockHandleSendEmailOtp} + resendCooldownDuration={0} /> ) diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx index bb5f1aece4..062f544911 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx @@ -205,12 +205,12 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG } // Handle sending OTP email - const handleSendEmailOtp = async (email) => { + const handleSendEmailOtp = async (email, isResend = false) => { // Normalize email for comparison (trim and lowercase) const normalizedEmail = email?.trim().toLowerCase() || '' - // Skip if email hasn't changed from the last one we sent - if (lastEmailSentRef.current === normalizedEmail) { + // Skip if email hasn't changed from the last one we sent (unless user requested) + if (!isResend && lastEmailSentRef.current === normalizedEmail) { // Return cached result if we have one if (otpSendPromiseRef.current) { return otpSendPromiseRef.current @@ -219,8 +219,8 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG return {isRegistered: false} } - // Reuse in-flight request (single-flight) across blur and submit - if (otpSendPromiseRef.current) { + // Reuse in-flight request (single-flight) across blur and submit (but not for explicit resend) + if (!isResend && otpSendPromiseRef.current) { return otpSendPromiseRef.current } 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 a16ca22de6..11a84743c8 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 @@ -3079,20 +3079,6 @@ "value": "Resend Code" } ], - "otp.button.resend_timer": [ - { - "type": 0, - "value": "Resend code in " - }, - { - "type": 1, - "value": "timer" - }, - { - "type": 0, - "value": " seconds..." - } - ], "otp.error.invalid_code": [ { "type": 0, @@ -3119,10 +3105,46 @@ "value": "To log in to your account, enter the code sent to your email." } ], - "otp.message.verifying": [ + "otp.message.resend_cooldown": [ { "type": 0, - "value": "Verifying code..." + "value": "You can request a new code in " + }, + { + "type": 1, + "value": "timer" + }, + { + "type": 0, + "value": " " + }, + { + "offset": 0, + "options": { + "one": { + "value": [ + { + "type": 0, + "value": "second" + } + ] + }, + "other": { + "value": [ + { + "type": 0, + "value": "seconds" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "timer" + }, + { + "type": 0, + "value": "." } ], "otp.title.confirm_its_you": [ 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 a16ca22de6..11a84743c8 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 @@ -3079,20 +3079,6 @@ "value": "Resend Code" } ], - "otp.button.resend_timer": [ - { - "type": 0, - "value": "Resend code in " - }, - { - "type": 1, - "value": "timer" - }, - { - "type": 0, - "value": " seconds..." - } - ], "otp.error.invalid_code": [ { "type": 0, @@ -3119,10 +3105,46 @@ "value": "To log in to your account, enter the code sent to your email." } ], - "otp.message.verifying": [ + "otp.message.resend_cooldown": [ { "type": 0, - "value": "Verifying code..." + "value": "You can request a new code in " + }, + { + "type": 1, + "value": "timer" + }, + { + "type": 0, + "value": " " + }, + { + "offset": 0, + "options": { + "one": { + "value": [ + { + "type": 0, + "value": "second" + } + ] + }, + "other": { + "value": [ + { + "type": 0, + "value": "seconds" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "timer" + }, + { + "type": 0, + "value": "." } ], "otp.title.confirm_its_you": [ 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 e84e21e7b3..cf20e0344c 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 @@ -6511,28 +6511,6 @@ "value": "]" } ], - "otp.button.resend_timer": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Řḗḗşḗḗƞḓ ƈǿǿḓḗḗ īƞ " - }, - { - "type": 1, - "value": "timer" - }, - { - "type": 0, - "value": " şḗḗƈǿǿƞḓş..." - }, - { - "type": 0, - "value": "]" - } - ], "otp.error.invalid_code": [ { "type": 0, @@ -6583,14 +6561,50 @@ "value": "]" } ], - "otp.message.verifying": [ + "otp.message.resend_cooldown": [ { "type": 0, "value": "[" }, { "type": 0, - "value": "Ṽḗḗřīƒẏīƞɠ ƈǿǿḓḗḗ..." + "value": "Ẏǿǿŭŭ ƈȧȧƞ řḗḗɋŭŭḗḗşŧ ȧȧ ƞḗḗẇ ƈǿǿḓḗḗ īƞ " + }, + { + "type": 1, + "value": "timer" + }, + { + "type": 0, + "value": " " + }, + { + "offset": 0, + "options": { + "one": { + "value": [ + { + "type": 0, + "value": "şḗḗƈǿǿƞḓ" + } + ] + }, + "other": { + "value": [ + { + "type": 0, + "value": "şḗḗƈǿǿƞḓş" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "timer" + }, + { + "type": 0, + "value": "." }, { "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 a5629d84f1..30ed890c49 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -1294,9 +1294,6 @@ "otp.button.resend_code": { "defaultMessage": "Resend Code" }, - "otp.button.resend_timer": { - "defaultMessage": "Resend code in {timer} seconds..." - }, "otp.error.invalid_code": { "defaultMessage": "The code is invalid or expired. Click Resend Code and try again." }, @@ -1306,8 +1303,8 @@ "otp.message.enter_code_for_account_returning": { "defaultMessage": "To log in to your account, enter the code sent to your email." }, - "otp.message.verifying": { - "defaultMessage": "Verifying code..." + "otp.message.resend_cooldown": { + "defaultMessage": "You can request a new code in {timer} {timer, plural, one {second} other {seconds}}." }, "otp.title.confirm_its_you": { "defaultMessage": "Confirm it's you" diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index a5629d84f1..30ed890c49 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -1294,9 +1294,6 @@ "otp.button.resend_code": { "defaultMessage": "Resend Code" }, - "otp.button.resend_timer": { - "defaultMessage": "Resend code in {timer} seconds..." - }, "otp.error.invalid_code": { "defaultMessage": "The code is invalid or expired. Click Resend Code and try again." }, @@ -1306,8 +1303,8 @@ "otp.message.enter_code_for_account_returning": { "defaultMessage": "To log in to your account, enter the code sent to your email." }, - "otp.message.verifying": { - "defaultMessage": "Verifying code..." + "otp.message.resend_cooldown": { + "defaultMessage": "You can request a new code in {timer} {timer, plural, one {second} other {seconds}}." }, "otp.title.confirm_its_you": { "defaultMessage": "Confirm it's you" From 47af76012d4e3018992010eec42e878bd6863c35 Mon Sep 17 00:00:00 2001 From: sf-deepali-bharmal Date: Thu, 5 Feb 2026 13:26:58 -0800 Subject: [PATCH 03/11] @W-21188588 Default to ECOM Shipping when OMS does not return shipping (#3639) * Default to ECOM Shipping when OMS does not return shipping * Add changelog * lint fix * nit optional check * variable rename --- .../template-retail-react-app/CHANGELOG.md | 1 + .../app/pages/account/order-detail.jsx | 21 ++++--- .../app/pages/account/orders.test.js | 55 +++++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index d9a4f55b41..2081dd866c 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -7,6 +7,7 @@ - Integrate Order History page to display data from OMS [#3581](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3581) - Add shipping display support for OMS [#3588](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3588) - BOPIS multishipment with OMS [#3613] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3613) + - Default to ECOM shipments in case OMS has no shipments [#3639] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3639) - [Feature] Update passwordless login and password reset to use email mode by default. The mode can now be configured across the login page, auth modal, and checkout page [#3525](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3525) - Update "Continue Securely" button text to "Continue" for passwordless login [#3556](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3556) - Util function for passwordless callback URI [#3630](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3630) diff --git a/packages/template-retail-react-app/app/pages/account/order-detail.jsx b/packages/template-retail-react-app/app/pages/account/order-detail.jsx index b5987f0cfc..1004c41659 100644 --- a/packages/template-retail-react-app/app/pages/account/order-detail.jsx +++ b/packages/template-retail-react-app/app/pages/account/order-detail.jsx @@ -137,12 +137,18 @@ const AccountOrderDetail = () => { // Check if order has OMS data const isOmsOrder = useMemo(() => !!order?.omsData, [order?.omsData]) - // Check if order is multi-shipment order + const omsShipmentCount = order?.omsData?.shipments?.length ?? 0 + const ecomShipmentCount = order?.shipments?.length ?? 0 + + const hasOmsShipment = useMemo(() => omsShipmentCount > 0, [omsShipmentCount]) + const isMultiShipmentOrder = useMemo( - () => (order?.omsData?.shipments?.length ?? 0) > 1 || (order?.shipments?.length ?? 0) > 1, - [isOmsOrder, order?.omsData?.shipments?.length, order?.shipments?.length] + () => omsShipmentCount > 1 || ecomShipmentCount > 1, + [omsShipmentCount, ecomShipmentCount] ) + const showMultiShipmentsFromOmsOnly = isOmsOrder && hasOmsShipment && isMultiShipmentOrder + const {pickupShipments, deliveryShipments} = useMemo(() => { return storeLocatorEnabled ? groupShipmentsByDeliveryOption(order) @@ -396,14 +402,14 @@ const AccountOrderDetail = () => { ) })} {/* Any type of Non-OMS or any type of single shipment order: show DeliveryMethods and Shipments info*/} - {(!isOmsOrder || !isMultiShipmentOrder) && + {!showMultiShipmentsFromOmsOnly && deliveryShipments.map((shipment, index) => { const omsShipment = isOmsOrder ? order.omsData.shipments?.[index] : null const shippingMethodName = - omsShipment?.provider || shipment.shippingMethod.name + omsShipment?.provider || shipment.shippingMethod?.name const shippingStatus = omsShipment?.status || shipment.shippingStatus const trackingNumber = @@ -457,8 +463,7 @@ const AccountOrderDetail = () => { })} {/* Any OMS multi-shipment: Only show OMS Shipments info;*/} - {isOmsOrder && - isMultiShipmentOrder && + {showMultiShipmentsFromOmsOnly && order?.omsData?.shipments?.map((shipment, index) => ( {renderShippingMethod( @@ -466,7 +471,7 @@ const AccountOrderDetail = () => { shipment.status, shipment.trackingNumber, shipment.trackingUrl, - order?.omsData?.shipments?.length ?? 0, + omsShipmentCount, index )} diff --git a/packages/template-retail-react-app/app/pages/account/orders.test.js b/packages/template-retail-react-app/app/pages/account/orders.test.js index 051c16b0e2..295f624677 100644 --- a/packages/template-retail-react-app/app/pages/account/orders.test.js +++ b/packages/template-retail-react-app/app/pages/account/orders.test.js @@ -1388,3 +1388,58 @@ describe('BOPIS Order - ECOM Only (No OMS)', () => { expect(screen.queryByRole('heading', {name: /shipping address/i})).not.toBeInTheDocument() }) }) + +describe('OMS order with no OMS shipments - default to ECOM shipment display (multi-ship)', () => { + // Multi-shipment scenario: OMS order has omsData but no OMS shipments. + const omsOrderMultiShipNoOmsShipments = createMockOmsOrder({ + omsData: { + status: 'Created' + }, + shipments: [ + { + shipmentId: 'ship1', + shippingMethod: {name: 'Ground'}, + shippingAddress: { + fullName: 'Alex Johnson', + address1: '876 NE 8th st', + city: 'Seattle', + stateCode: 'WA', + postalCode: '98121', + countryCode: 'US' + } + }, + { + shipmentId: 'ship2', + shippingMethod: {name: 'Express'}, + shippingAddress: { + fullName: 'Bob Smith', + address1: '456 Second St', + city: 'Portland', + stateCode: 'OR', + postalCode: '97201', + countryCode: 'US' + } + } + ] + }) + + test('should display multi-shipment Shipping Method and Shipping Address from ECOM when OMS has no shipments', async () => { + setupOrderDetailsPage(omsOrderMultiShipNoOmsShipments) + expect(await screen.findByTestId('account-order-details-page')).toBeInTheDocument() + // Default to ECOM delivery block (multi-shipment) when OMS has no shipments. + expect(await screen.findByRole('heading', {name: /shipping method 1/i})).toBeInTheDocument() + expect(await screen.findByRole('heading', {name: /shipping method 2/i})).toBeInTheDocument() + expect( + await screen.findByRole('heading', {name: /shipping address 1/i}) + ).toBeInTheDocument() + expect( + await screen.findByRole('heading', {name: /shipping address 2/i}) + ).toBeInTheDocument() + expect(await screen.findByText(/Alex Johnson/i)).toBeInTheDocument() + expect(await screen.findByText(/Bob Smith/i)).toBeInTheDocument() + expect(await screen.findByText(/876 NE 8th st/i)).toBeInTheDocument() + expect(await screen.findByText(/456 Second St/i)).toBeInTheDocument() + expect(await screen.findByText(/Ground/i)).toBeInTheDocument() + expect(await screen.findByText(/Express/i)).toBeInTheDocument() + }) +}) From c7a9e08ee6acda70d56ae579e2cc645c60c1232a Mon Sep 17 00:00:00 2001 From: Noah Adams Date: Thu, 5 Feb 2026 17:15:56 -0800 Subject: [PATCH 04/11] port in basepath changes from https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3641 --- .../template-mrt-reference-app/app/ssr.js | 17 +++++- .../app/ssr.test.js | 56 +++++++++++++------ 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/packages/template-mrt-reference-app/app/ssr.js b/packages/template-mrt-reference-app/app/ssr.js index 8d6cabf0f1..11adf89d80 100644 --- a/packages/template-mrt-reference-app/app/ssr.js +++ b/packages/template-mrt-reference-app/app/ssr.js @@ -65,6 +65,7 @@ const ENVS_TO_EXPOSE = [ 'aws_lambda_log_stream_name', 'aws_region', 'bundle_id', + 'mrt_env_base_path', // These "customer" defined environment variables are set by the Manager // and expected by the MRT smoke test suite 'customer_*', @@ -360,6 +361,19 @@ const loggingMiddleware = (req, res, next) => { return next() } +const envBasePathMiddleware = (req, res, next) => { + const basePath = process.env.MRT_ENV_BASE_PATH + console.log(`Base path: ${basePath}`) + console.log(`Request path: ${req.url}`) + if (basePath && req.url.startsWith(basePath)) { + req.url = req.path.slice(basePath.length) || '/' + console.log( + `Base path: Rewrote ${basePath} -> Request path: ${req.originalUrl} -> New path: ${req.url}` + ) + } + return next() +} + const options = { // The build directory (an absolute path) buildDir: path.resolve(process.cwd(), 'build'), @@ -394,7 +408,8 @@ const {handler, app, server} = runtime.createHandler(options, (app) => { // Add middleware to log request and response headers app.use(loggingMiddleware) - + // Add a middleware to consume the base path from the request path if one is set + app.use(envBasePathMiddleware) // Configure routes app.all('/exception', exception) app.get('/tls', tlsVersionTest) diff --git a/packages/template-mrt-reference-app/app/ssr.test.js b/packages/template-mrt-reference-app/app/ssr.test.js index b050374b0d..2412fe3f47 100644 --- a/packages/template-mrt-reference-app/app/ssr.test.js +++ b/packages/template-mrt-reference-app/app/ssr.test.js @@ -17,6 +17,25 @@ const { const {mockClient} = require('aws-sdk-client-mock') const {ServiceException} = require('@smithy/smithy-client') +const pathsToCheck = [ + ['/', 200, 'application/json; charset=utf-8'], + ['/tls', 200, 'application/json; charset=utf-8'], + ['/exception', 500, 'text/html; charset=utf-8'], + ['/cache', 200, 'application/json; charset=utf-8'], + ['/cookie', 200, 'application/json; charset=utf-8'], + ['/set-response-headers', 200, 'application/json; charset=utf-8'], + ['/isolation', 200, 'application/json; charset=utf-8'], + ['/memtest', 200, 'application/json; charset=utf-8'], + ['/streaming-large', 200, 'application/json; charset=utf-8'] +] +const pathsToCheckWithBasePath = (basePath) => { + return pathsToCheck.map((pathStatusContentType) => [ + basePath + pathStatusContentType[0], + pathStatusContentType[1], + pathStatusContentType[2] + ]) +} + class AccessDenied extends ServiceException { constructor(options) { super({...options, name: 'AccessDenied'}) @@ -53,23 +72,26 @@ describe('server', () => { process.env = originalEnv jest.restoreAllMocks() }) - test.each([ - ['/', 200, 'application/json; charset=utf-8'], - ['/tls', 200, 'application/json; charset=utf-8'], - ['/exception', 500, 'text/html; charset=utf-8'], - ['/cache', 200, 'application/json; charset=utf-8'], - ['/cookie', 200, 'application/json; charset=utf-8'], - ['/multi-cookies', 200, 'application/json; charset=utf-8'], - ['/set-response-headers', 200, 'application/json; charset=utf-8'], - ['/isolation', 200, 'application/json; charset=utf-8'], - ['/memtest', 200, 'application/json; charset=utf-8'], - ['/streaming-large', 200, 'application/json; charset=utf-8'] - ])('Path %p should render correctly', (path, expectedStatus, expectedContentType) => { - return request(app) - .get(path) - .expect(expectedStatus) - .expect('Content-Type', expectedContentType) - }) + test.each(pathsToCheck)( + 'Path %p should render correctly', + (path, expectedStatus, expectedContentType) => { + return request(app) + .get(path) + .expect(expectedStatus) + .expect('Content-Type', expectedContentType) + } + ) + + test.each(pathsToCheckWithBasePath('/test-base-path'))( + 'Path %p should render correctly', + (path, expectedStatus, expectedContentType) => { + process.env.MRT_ENV_BASE_PATH = '/test-base-path' + return request(app) + .get(path) + .expect(expectedStatus) + .expect('Content-Type', expectedContentType) + } + ) test('Path "/cache" has Cache-Control set', () => { return request(app).get('/cache').expect('Cache-Control', 's-maxage=60') From e3f21ad2e57f568467aa0c11c860260b8311dbf7 Mon Sep 17 00:00:00 2001 From: Noah Adams Date: Fri, 6 Feb 2026 16:37:04 -0800 Subject: [PATCH 05/11] feedback, check for path segment and not just prefix matching in middleware, test query strings are preserved, resolved path does not include base path, and base path exists in environment --- packages/template-mrt-reference-app/app/ssr.js | 6 +++--- packages/template-mrt-reference-app/app/ssr.test.js | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/template-mrt-reference-app/app/ssr.js b/packages/template-mrt-reference-app/app/ssr.js index 11adf89d80..1a8d2d1d45 100644 --- a/packages/template-mrt-reference-app/app/ssr.js +++ b/packages/template-mrt-reference-app/app/ssr.js @@ -365,10 +365,10 @@ const envBasePathMiddleware = (req, res, next) => { const basePath = process.env.MRT_ENV_BASE_PATH console.log(`Base path: ${basePath}`) console.log(`Request path: ${req.url}`) - if (basePath && req.url.startsWith(basePath)) { - req.url = req.path.slice(basePath.length) || '/' + if (basePath && req.path.startsWith(`${basePath}/` || req.path === basePath)) { + req.url = req.url.slice(basePath.length) || '/' console.log( - `Base path: Rewrote ${basePath} -> Request path: ${req.originalUrl} -> New path: ${req.url}` + `Base path: Rewrote ${basePath} -> Original url: ${req.originalUrl} -> New url: ${req.url}` ) } return next() diff --git a/packages/template-mrt-reference-app/app/ssr.test.js b/packages/template-mrt-reference-app/app/ssr.test.js index 2412fe3f47..c331cf8852 100644 --- a/packages/template-mrt-reference-app/app/ssr.test.js +++ b/packages/template-mrt-reference-app/app/ssr.test.js @@ -93,6 +93,17 @@ describe('server', () => { } ) + test('Path /echo should work with base path', async () => { + const basePath = '/test-base-path' + process.env.MRT_ENV_BASE_PATH = basePath + const response = await request(app).get(`${basePath}/echo?x=foo&y=bar`) + expect(response.status).toBe(200) + expect(response.body.query.x).toBe('foo') + expect(response.body.query.y).toBe('bar') + expect(response.body.path).toBe('/echo') + expect(response.body.env.MRT_ENV_BASE_PATH).toBe(basePath) + }) + test('Path "/cache" has Cache-Control set', () => { return request(app).get('/cache').expect('Cache-Control', 's-maxage=60') }) From 07a6c5abab0f81c6b6c7c71f7ecf1f1c0cda7c48 Mon Sep 17 00:00:00 2001 From: Noah Adams Date: Mon, 9 Feb 2026 11:43:15 -0800 Subject: [PATCH 06/11] correct parentheses, comment test-cases --- packages/template-mrt-reference-app/app/ssr.js | 2 +- packages/template-mrt-reference-app/app/ssr.test.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/template-mrt-reference-app/app/ssr.js b/packages/template-mrt-reference-app/app/ssr.js index 1a8d2d1d45..66d2cf094c 100644 --- a/packages/template-mrt-reference-app/app/ssr.js +++ b/packages/template-mrt-reference-app/app/ssr.js @@ -365,7 +365,7 @@ const envBasePathMiddleware = (req, res, next) => { const basePath = process.env.MRT_ENV_BASE_PATH console.log(`Base path: ${basePath}`) console.log(`Request path: ${req.url}`) - if (basePath && req.path.startsWith(`${basePath}/` || req.path === basePath)) { + if (basePath && (req.path.startsWith(`${basePath}/`) || req.path === basePath)) { req.url = req.url.slice(basePath.length) || '/' console.log( `Base path: Rewrote ${basePath} -> Original url: ${req.originalUrl} -> New url: ${req.url}` diff --git a/packages/template-mrt-reference-app/app/ssr.test.js b/packages/template-mrt-reference-app/app/ssr.test.js index c331cf8852..19317ba82f 100644 --- a/packages/template-mrt-reference-app/app/ssr.test.js +++ b/packages/template-mrt-reference-app/app/ssr.test.js @@ -98,9 +98,12 @@ describe('server', () => { process.env.MRT_ENV_BASE_PATH = basePath const response = await request(app).get(`${basePath}/echo?x=foo&y=bar`) expect(response.status).toBe(200) + // preserves query parameters expect(response.body.query.x).toBe('foo') expect(response.body.query.y).toBe('bar') + // path is the path after the base path expect(response.body.path).toBe('/echo') + // base path env var present in response body expect(response.body.env.MRT_ENV_BASE_PATH).toBe(basePath) }) From a9a452529b93917c0d6776ec42021fadae288648 Mon Sep 17 00:00:00 2001 From: syadupathi-sf <66088780+syadupathi-sf@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:34:07 -0500 Subject: [PATCH 07/11] W-21190081 Add spinner during OTP verification (#3643) --- .../app/components/otp-auth/index.jsx | 12 +++++++ .../app/components/otp-auth/index.test.js | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/template-retail-react-app/app/components/otp-auth/index.jsx b/packages/template-retail-react-app/app/components/otp-auth/index.jsx index 886c83ce52..0e36aceda2 100644 --- a/packages/template-retail-react-app/app/components/otp-auth/index.jsx +++ b/packages/template-retail-react-app/app/components/otp-auth/index.jsx @@ -12,6 +12,7 @@ import { Button, Input, SimpleGrid, + Spinner, Stack, Text, HStack, @@ -271,6 +272,17 @@ const OtpAuth = ({ ))} + {/* Loading spinner during verification */} + {isVerifying && ( + + )} + {/* Error message */} {error && ( diff --git a/packages/template-retail-react-app/app/components/otp-auth/index.test.js b/packages/template-retail-react-app/app/components/otp-auth/index.test.js index 06e2a2d049..d238c89e33 100644 --- a/packages/template-retail-react-app/app/components/otp-auth/index.test.js +++ b/packages/template-retail-react-app/app/components/otp-auth/index.test.js @@ -246,6 +246,41 @@ describe('OtpAuth', () => { await user.type(otpInputs[7], '8') expect(otpInputs[7]).toHaveFocus() }) + + test('shows spinner while OTP is being verified', async () => { + const deferred = {} + const verifyingPromise = new Promise((resolve) => { + deferred.resolve = resolve + }) + const mockVerify = jest.fn().mockReturnValue(verifyingPromise) + + const user = userEvent.setup() + renderWithProviders( + + ) + + expect(screen.queryByTestId('otp-verifying-spinner')).not.toBeInTheDocument() + + const otpInputs = screen.getAllByRole('textbox') + fireEvent.paste(otpInputs[0], { + clipboardData: {getData: () => '12345678'} + }) + + await waitFor(() => { + expect(screen.getByTestId('otp-verifying-spinner')).toBeInTheDocument() + }) + + deferred.resolve({success: true}) + await waitFor(() => { + expect(screen.queryByTestId('otp-verifying-spinner')).not.toBeInTheDocument() + }) + }) }) describe('Keyboard Navigation', () => { From 9395d615e7c5a87f791441d09de555fbe58b9676 Mon Sep 17 00:00:00 2001 From: syadupathi-sf <66088780+syadupathi-sf@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:19:44 -0500 Subject: [PATCH 08/11] @W-21111863 @W-21109829 @W-21005976 @W-21109850 1CC Bug Fixes (#3638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * @W-21109850: Continue as Guest skips entering phone number (#3626) * One click checkout * changes to fix install, tests and lint - needs to be reviewed * revert the test change in pwa-kit-runtime * @W-20892497 Show Phone number in Contact Info summary (#3576) * W-20892497 Show Phone number in Contact Info summary * fix lint * @W-20892592 Remove gift messaging for multi shipment (#3579) * W-20892592 Remove gift messaging for multi shipment * translations * @W-20892530 @W-20892577 Billing Address Validation and Using contact phone for user registration (#3583) * W-20892530 Billing Address Validation * W-20892577 save contact info phone * Fix SDK tests (#3593) * fix sdk tests and app bundle size * fix lint * @ W-20540715 Address 1CC feature branch review comments (#3619) * address first set of comments * address rest of code review comments * reverting default.js changes * fix package versions * shipping options fix * attempt to fix flaky tests * passwordless mode updates * @W-21109850: Continue as Guest skips entering phone number Signed-off-by: d.phan * fix import Signed-off-by: d.phan * translations * fix user not found error --------- Signed-off-by: d.phan Co-authored-by: Sushma Yadupathi Co-authored-by: syadupathi-sf <66088780+syadupathi-sf@users.noreply.github.com> * W-21111863 Hide user registration for returning shoppers who chose to checkout as guest (#3634) * @W-21005976 Save newly registered user's info when they leave checkout… (#3632) * W-21005976 Save newly registered user's info when they leave checkout and return * code review comments * @W-21109829 Editing shipping options in multi shipment scenarios (#3637) * W-21109829 Editing shipping options in multi shipment scenarios * minor text changes * remove unnecessary test --------- Signed-off-by: d.phan Signed-off-by: syadupathi-sf <66088780+syadupathi-sf@users.noreply.github.com> Co-authored-by: Danny Phan <125327707+dannyphan2000@users.noreply.github.com> --- .../app/pages/checkout-one-click/index.jsx | 93 ++--------------- .../partials/one-click-contact-info.jsx | 46 +++------ .../partials/one-click-contact-info.test.js | 81 ++++++++------- .../partials/one-click-payment.jsx | 45 ++++----- .../partials/one-click-shipping-options.jsx | 14 +-- .../one-click-shipping-options.test.js | 99 ++++++++++++++++++- .../partials/one-click-user-registration.jsx | 88 ++++++++++++++++- .../one-click-user-registration.test.js | 20 +++- .../util/checkout-context.js | 20 ++++ 9 files changed, 316 insertions(+), 190 deletions(-) diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx index 5f7b338393..1b6274dc2d 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/index.jsx @@ -31,7 +31,9 @@ import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' import { useCheckout, - CheckoutProvider + CheckoutProvider, + getCheckoutGuestChoiceFromStorage, + setCheckoutGuestChoiceInStorage } from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context' import ContactInfo from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info' import PickupAddress from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-pickup-address' @@ -55,16 +57,17 @@ import { getPaymentInstrumentCardType, getMaskCreditCardNumber } from '@salesforce/retail-react-app/app/utils/cc-utils' -import {nanoid} from 'nanoid' const CheckoutOneClick = () => { const {formatMessage} = useIntl() const navigate = useNavigation() - const {step, STEPS, contactPhone} = useCheckout() + const {step, STEPS} = useCheckout() const showToast = useToast() const [isLoading, setIsLoading] = useState(false) const [enableUserRegistration, setEnableUserRegistration] = useState(false) - const [registeredUserChoseGuest, setRegisteredUserChoseGuest] = useState(false) + const [registeredUserChoseGuest, setRegisteredUserChoseGuest] = useState( + getCheckoutGuestChoiceFromStorage + ) const [shouldSavePaymentMethod, setShouldSavePaymentMethod] = useState(false) const [isOtpLoading, setIsOtpLoading] = useState(false) const [isPlacingOrder, setIsPlacingOrder] = useState(false) @@ -119,8 +122,6 @@ const CheckoutOneClick = () => { ShopperBasketsMutations.UpdateBillingAddressForBasket ) const {mutateAsync: createOrder} = useShopperOrdersMutation(ShopperOrdersMutations.CreateOrder) - const createCustomerAddress = useShopperCustomersMutation('createCustomerAddress') - const updateCustomer = useShopperCustomersMutation('updateCustomer') const handleSavePreferenceChange = (shouldSave) => { setShouldSavePaymentMethod(shouldSave) @@ -382,87 +383,9 @@ const CheckoutOneClick = () => { fullCardDetails ) } - - // For newly registered guests only, persist shipping address when billing same as shipping - // Skip saving pickup/store addresses - only save delivery addresses - // For multi-shipment orders, save all delivery addresses with the first one as default - if ( - enableUserRegistration && - currentCustomer?.isRegistered && - !registeredUserChoseGuest - ) { - try { - const customerId = order.customerInfo?.customerId - if (!customerId) return - - // Get all delivery shipments (not pickup) from the order - // This handles both single delivery and multi-shipment orders - // For BOPIS orders, pickup shipments are filtered out - const deliveryShipments = - order?.shipments?.filter( - (shipment) => - !isPickupShipment(shipment) && shipment.shippingAddress - ) || [] - - if (deliveryShipments.length > 0) { - // Save all delivery addresses, with the first one as preferred - for (let i = 0; i < deliveryShipments.length; i++) { - const shipment = deliveryShipments[i] - const shipping = shipment.shippingAddress - if (!shipping) continue - - // Whitelist fields and strip non-customer fields (e.g., id, _type) - const { - address1, - address2, - city, - countryCode, - firstName, - lastName, - phone, - postalCode, - stateCode - } = shipping || {} - - await createCustomerAddress.mutateAsync({ - parameters: {customerId}, - body: { - addressId: nanoid(), - preferred: i === 0, // First address is preferred - address1, - address2, - city, - countryCode, - firstName, - lastName, - phone, - postalCode, - stateCode - } - }) - } - } - - // Persist phone number as phoneHome for newly registered guest shoppers - const phoneHome = basket?.billingAddress?.phone || contactPhone - if (phoneHome) { - await updateCustomer.mutateAsync({ - parameters: {customerId}, - body: {phoneHome} - }) - } - } catch (_e) { - // Only surface error if shopper opted to register/save details; otherwise fail silently - showError( - formatMessage({ - id: 'checkout.error.cannot_save_address', - defaultMessage: 'Could not save shipping address.' - }) - ) - } - } } + setCheckoutGuestChoiceInStorage(false) navigate(`/checkout/confirmation/${order.orderNo}`) } catch (error) { const message = formatMessage({ diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx index 062f544911..19fe7da189 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.jsx @@ -26,7 +26,10 @@ import { } from '@salesforce/retail-react-app/app/components/shared/ui' import {useForm} from 'react-hook-form' import {FormattedMessage, useIntl} from 'react-intl' -import {useCheckout} from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context' +import { + useCheckout, + setCheckoutGuestChoiceInStorage +} from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context' import useLoginFields from '@salesforce/retail-react-app/app/components/forms/useLoginFields' import { ToggleCard, @@ -110,7 +113,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG const [isCheckingEmail, setIsCheckingEmail] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [isBlurChecking, setIsBlurChecking] = useState(false) - const [, setRegisteredUserChoseGuest] = useState(false) + const [registeredUserChoseGuest, setRegisteredUserChoseGuest] = useState(false) const [emailError, setEmailError] = useState('') // Auto-focus the email field when the component mounts @@ -270,37 +273,11 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG } // Handle checkout as guest from OTP modal - const handleCheckoutAsGuest = async () => { - try { - const email = form.getValues('email') - const phone = form.getValues('phone') - // Update basket with guest email - await updateCustomerForBasket.mutateAsync({ - parameters: {basketId: basket.basketId}, - body: {email: email} - }) - - // Save phone number to basket billing address for guest shoppers - if (phone) { - await updateBillingAddressForBasket.mutateAsync({ - parameters: {basketId: basket.basketId}, - body: { - ...basket?.billingAddress, - phone: phone - } - }) - } - - // Set the flag that "Checkout as Guest" was clicked - setRegisteredUserChoseGuest(true) - if (onRegisteredUserChoseGuest) { - onRegisteredUserChoseGuest(true) - } - - // Proceed to next step (shipping address) - goToNextStep() - } catch (error) { - setError(error.message) + const handleCheckoutAsGuest = () => { + setRegisteredUserChoseGuest(true) + setCheckoutGuestChoiceInStorage(true) + if (onRegisteredUserChoseGuest) { + onRegisteredUserChoseGuest(true) } } @@ -359,6 +336,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG // Reset guest checkout flag since user is now logged in setRegisteredUserChoseGuest(false) + setCheckoutGuestChoiceInStorage(false) if (onRegisteredUserChoseGuest) { onRegisteredUserChoseGuest(false) } @@ -470,7 +448,7 @@ const ContactInfo = ({isSocialEnabled = false, idps = [], onRegisteredUserChoseG return } - if (!result.isRegistered) { + if (!result.isRegistered || registeredUserChoseGuest) { // Guest shoppers must provide phone number before proceeding const phone = (formData.phone || '').trim() if (!phone) { diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js index 0a2ee05fee..f55bb54646 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info.test.js @@ -7,6 +7,7 @@ import React from 'react' import {screen, waitFor, fireEvent, act} from '@testing-library/react' import ContactInfo from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-contact-info' +import {setCheckoutGuestChoiceInStorage} from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' import {rest} from 'msw' import {AuthHelpers, useCustomerType} from '@salesforce/commerce-sdk-react' @@ -79,6 +80,7 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ( const mockSetContactPhone = jest.fn() const mockGoToNextStep = jest.fn() jest.mock('@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context', () => { + const setCheckoutGuestChoiceInStorage = jest.fn() return { useCheckout: jest.fn().mockReturnValue({ customer: null, @@ -91,7 +93,8 @@ jest.mock('@salesforce/retail-react-app/app/pages/checkout-one-click/util/checko goToStep: null, goToNextStep: mockGoToNextStep, setContactPhone: mockSetContactPhone - }) + }), + setCheckoutGuestChoiceInStorage } }) @@ -110,13 +113,17 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-multi-site', () => ({ // Mock OtpAuth to expose a verify trigger jest.mock('@salesforce/retail-react-app/app/components/otp-auth', () => { // eslint-disable-next-line react/prop-types - return function MockOtpAuth({isOpen, handleOtpVerification, onCheckoutAsGuest}) { + return function MockOtpAuth({isOpen, handleOtpVerification, onCheckoutAsGuest, onClose}) { + const handleGuestClick = () => { + onCheckoutAsGuest?.() + onClose?.() + } return isOpen ? (
Confirm it's you

To log in to your account, enter the code sent to your email.

- @@ -289,7 +296,8 @@ describe('ContactInfo Component', () => { goToStep: jest.fn(), goToNextStep: jest.fn(), setContactPhone: jest.fn() - }) + }), + setCheckoutGuestChoiceInStorage: jest.fn() } } ) @@ -606,11 +614,10 @@ describe('ContactInfo Component', () => { expect(screen.getByText(/Resend Code/i)).toBeInTheDocument() }) - test('shows error message when updateCustomerForBasket fails', async () => { - // Mock OTP authorization to succeed so modal opens + test('clicking "Checkout as a guest" does not update basket or advance step', async () => { + // "Checkout as Guest" only closes the modal and sets registeredUserChoseGuest state; + // basket is updated when the user later submits the form with phone and clicks Continue. mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockResolvedValue({}) - // Mock update to fail when choosing guest - mockUpdateCustomerForBasket.mutateAsync.mockRejectedValue(new Error('API Error')) const {user} = renderWithProviders() const emailInput = screen.getByLabelText('Email') @@ -625,22 +632,17 @@ describe('ContactInfo Component', () => { await user.click(submitButton) await screen.findByTestId('otp-verify') - // Click "Checkout as a guest" which triggers updateCustomerForBasket and should set error + // Click "Checkout as a guest" — should not call basket mutations or goToNextStep await user.click(screen.getByText(/Checkout as a guest/i)) await waitFor(() => { - expect(mockUpdateCustomerForBasket.mutateAsync).toHaveBeenCalled() - }) - // Error alert should be rendered; component maps errors via getPasswordlessErrorMessage to generic message - await waitFor(() => { - const alerts = screen.queryAllByRole('alert') - const hasError = alerts.some( - (n) => - n.textContent?.includes('Something went wrong') || - n.textContent?.includes('API Error') - ) - expect(hasError).toBe(true) + expect(mockUpdateCustomerForBasket.mutateAsync).not.toHaveBeenCalled() + expect(mockGoToNextStep).not.toHaveBeenCalled() }) + // Modal closes; user stays on Contact Info (Continue button visible again) + expect( + screen.getByRole('button', {name: /continue to shipping address/i}) + ).toBeInTheDocument() }) test('does not proceed to next step when OTP modal is already open on form submission', async () => { @@ -747,37 +749,41 @@ describe('ContactInfo Component', () => { expect(phoneInput.value).toBe('(555) 123-4567') }) - test('saves phone number to billing address when guest checks out via "Checkout as Guest" button', async () => { - // Mock successful OTP authorization to open modal + test('notifies parent when guest chooses "Checkout as Guest" and stays on Contact Info', async () => { + // Open OTP modal (registered email), click "Checkout as a guest" — modal closes, + // parent is notified via onRegisteredUserChoseGuest(true), user stays on Contact Info. mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync.mockResolvedValue({}) - mockUpdateCustomerForBasket.mutateAsync.mockResolvedValue({}) - mockUpdateBillingAddressForBasket.mutateAsync.mockResolvedValue({}) - const {user} = renderWithProviders() + const onRegisteredUserChoseGuestSpy = jest.fn() + const {user} = renderWithProviders( + + ) const emailInput = screen.getByLabelText('Email') - const phoneInput = screen.getByLabelText('Phone') - - // Enter phone first - use fireEvent to ensure value is set - fireEvent.change(phoneInput, {target: {value: '(727) 555-1234'}}) - // Enter email and wait for OTP modal to open + // Enter email and open OTP modal (blur triggers registered-user check) await user.type(emailInput, validEmail) fireEvent.change(emailInput, {target: {value: validEmail}}) fireEvent.blur(emailInput) - // Wait for OTP modal to open await screen.findByTestId('otp-verify') - // Click "Checkout as a guest" button + // Click "Checkout as a guest" — modal closes; parent is notified; no basket update await user.click(screen.getByText(/Checkout as a guest/i)) + expect(onRegisteredUserChoseGuestSpy).toHaveBeenCalledWith(true) + expect(setCheckoutGuestChoiceInStorage).toHaveBeenCalledWith(true) + expect(mockUpdateCustomerForBasket.mutateAsync).not.toHaveBeenCalled() + expect(mockGoToNextStep).not.toHaveBeenCalled() + + // Modal closes; user stays on Contact Info (Continue button visible for entering phone) await waitFor(() => { - expect(mockUpdateBillingAddressForBasket.mutateAsync).toHaveBeenCalled() - const callArgs = mockUpdateBillingAddressForBasket.mutateAsync.mock.calls[0]?.[0] - expect(callArgs?.parameters).toMatchObject({basketId: 'test-basket-id'}) - expect(callArgs?.body?.phone).toMatch(/727/) + expect(screen.queryByText("Confirm it's you")).not.toBeInTheDocument() }) + expect( + screen.getByRole('button', {name: /continue to shipping address/i}) + ).toBeInTheDocument() + expect(screen.getByLabelText('Phone')).toBeInTheDocument() }) test('uses phone from billing address when persisting to customer profile after OTP verification', async () => { @@ -836,5 +842,8 @@ describe('ContactInfo Component', () => { body: {phoneHome: billingPhone} }) }) + + // Guest choice storage should be cleared when user signs in via OTP + expect(setCheckoutGuestChoiceInStorage).toHaveBeenCalledWith(false) }) }) 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 790cc5eab4..d196cd228a 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 @@ -528,27 +528,28 @@ const Payment = ({ isBillingAddress /> )} - {(isGuest || showRegistrationNotice) && ( - - )} + {(isGuest || showRegistrationNotice) && + !registeredUserChoseGuest && ( + + )} ) : null} @@ -594,7 +595,7 @@ const Payment = ({ )} - {(isGuest || showRegistrationNotice) && ( + {(isGuest || showRegistrationNotice) && !registeredUserChoseGuest && ( m.id === defaultMethodId) || methods[0] }, + // Skip auto-apply when a valid method is already selected. + // When the shopper clicked "Change" to edit shippingoptions, stay on the edit view. shouldSkip: () => { if (selectedShippingMethod?.id && !isPickupMethod(selectedShippingMethod)) { const stillValid = deliveryMethods.some((m) => m.id === selectedShippingMethod.id) - if (stillValid) { - goToNextStep() - return true - } + if (stillValid) return true } return false }, @@ -160,8 +159,11 @@ export default function ShippingOptions() { ) }, [step, customer, selectedShippingMethod, shippingMethods, STEPS.SHIPPING_OPTIONS]) - // Use calculated loading state or auto-select loading state - const effectiveIsLoading = Boolean(isAutoSelectLoading) || Boolean(shouldShowInitialLoading) + // Use calculated loading state or auto-select loading state only for single-shipment. + // For multi-shipment, each ShipmentMethods fetches its own methods + const effectiveIsLoading = hasMultipleDeliveryShipments + ? false + : Boolean(isAutoSelectLoading) || Boolean(shouldShowInitialLoading) const form = useForm({ shouldUnregister: false, diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.test.js b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.test.js index 2fde2322b8..07b9630a00 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.test.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options.test.js @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import React from 'react' -import {screen, waitFor} from '@testing-library/react' +import {screen, waitFor, within} from '@testing-library/react' import ShippingOptions from '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' @@ -341,7 +341,7 @@ describe('ShippingOptions Component', () => { }) describe('for registered users with auto-selection', () => { - test('skips shipping method update when existing method is still valid', async () => { + test('skips shipping method update when existing method is still valid and stays on edit view', async () => { jest.resetModules() jest.doMock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({ @@ -376,10 +376,14 @@ describe('ShippingOptions Component', () => { localRenderWithProviders() await waitFor(() => { - expect(mockGoToNextStep).toHaveBeenCalled() + expect(mockUpdateShippingMethod.mutateAsync).not.toHaveBeenCalled() }) - expect(mockUpdateShippingMethod.mutateAsync).not.toHaveBeenCalled() + // Does not auto-advance so user can change option or click Continue (fixes "Change" flicker) + expect(mockGoToNextStep).not.toHaveBeenCalled() + expect( + screen.getAllByRole('button', {name: /continue to payment/i}).length + ).toBeGreaterThan(0) }) test('auto-selects default method when existing method is no longer valid', async () => { @@ -798,6 +802,93 @@ describe('ShippingOptions Component', () => { expect(screen.getByText('Continue to Payment')).toBeInTheDocument() }) + test('multi-shipment edit view shows shipping options for each shipment', async () => { + jest.resetModules() + + jest.doMock( + '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context', + () => ({ + useCheckout: jest.fn().mockReturnValue({ + step: 3, + STEPS: { + CONTACT_INFO: 0, + PICKUP_ADDRESS: 1, + SHIPPING_ADDRESS: 2, + SHIPPING_OPTIONS: 3, + PAYMENT: 4 + }, + goToStep: mockGoToStep, + goToNextStep: mockGoToNextStep + }) + }) + ) + jest.doMock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({ + useCurrentCustomer: () => ({ + data: {customerId: 'test-customer-id', isRegistered: true} + }) + })) + jest.doMock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => ({ + useCurrentBasket: () => ({ + data: { + basketId: 'test-basket-id', + shipments: [ + { + shipmentId: 'ship1', + shippingAddress: { + firstName: 'Oscar', + lastName: 'Robertson', + address1: '333 South St', + city: 'West Lafayette', + stateCode: 'IN', + postalCode: '98103' + }, + shippingMethod: {id: 'std', name: 'Standard'} + }, + { + shipmentId: 'ship2', + shippingAddress: { + firstName: 'Lee', + lastName: 'Robertson', + address1: '158 South St', + city: 'West Lafayette', + stateCode: 'IN', + postalCode: '98103' + }, + shippingMethod: {id: 'std2', name: 'Standard 2'} + } + ], + shippingItems: [ + {shipmentId: 'ship1', price: 0}, + {shipmentId: 'ship2', price: 0} + ] + }, + derivedData: {hasBasket: true, totalItems: 2, totalShippingCost: 0} + }) + })) + + const sdk = await import('@salesforce/commerce-sdk-react') + sdk.useShippingMethodsForShipment.mockImplementation(({parameters}) => { + if (parameters.shipmentId === 'ship1') return {data: multiShipMethods1} + if (parameters.shipmentId === 'ship2') return {data: multiShipMethods2} + return {data: multiShipMethods1} + }) + + const {renderWithProviders: localRenderWithProviders} = await import( + '@salesforce/retail-react-app/app/utils/test-utils' + ) + const module = await import( + '@salesforce/retail-react-app/app/pages/checkout-one-click/partials/one-click-shipping-options' + ) + + localRenderWithProviders() + + const cards = screen.getAllByTestId('sf-toggle-card-step-2') + expect(cards.length).toBeGreaterThan(0) + expect(within(cards[0]).queryByTestId('loading')).toBeNull() + expect(screen.getAllByText('Shipment 1:').length).toBeGreaterThan(0) + expect(screen.getAllByText('Shipment 2:').length).toBeGreaterThan(0) + }) + test('auto-selects default method when no method is set on shipment', async () => { jest.resetModules() 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 ca6612ce4a..5c918bef48 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 @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import React, {useRef, useState, useEffect} from 'react' -import {FormattedMessage} from 'react-intl' +import {FormattedMessage, useIntl} from 'react-intl' import PropTypes from 'prop-types' import { Box, @@ -23,7 +23,12 @@ import { import OtpAuth from '@salesforce/retail-react-app/app/components/otp-auth' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import {useCustomerType, useAuthHelper, AuthHelpers} from '@salesforce/commerce-sdk-react' +import {useShopperCustomersMutation} from '@salesforce/commerce-sdk-react' import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' +import {useCheckout} from '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context' +import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' +import {isPickupShipment} from '@salesforce/retail-react-app/app/utils/shipment-utils' +import {nanoid} from 'nanoid' export default function UserRegistration({ enableUserRegistration, @@ -36,15 +41,28 @@ export default function UserRegistration({ onLoadingChange }) { const {data: basket} = useCurrentBasket() + const {contactPhone} = useCheckout() const {isGuest} = useCustomerType() const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser) const {locale} = useMultiSite() + const {formatMessage} = useIntl() + const showToast = useToast() + const createCustomerAddress = useShopperCustomersMutation('createCustomerAddress') + const updateCustomer = useShopperCustomersMutation('updateCustomer') + const {isOpen: isOtpOpen, onOpen: onOtpOpen, onClose: onOtpClose} = useDisclosure() const otpSentRef = useRef(false) const [registrationSucceeded, setRegistrationSucceeded] = useState(false) const [isLoadingOtp, setIsLoadingOtp] = useState(false) + const showError = (message) => { + showToast({ + title: message, + status: 'error' + }) + } + const handleOtpClose = () => { otpSentRef.current = false onOtpClose() @@ -92,13 +110,79 @@ export default function UserRegistration({ } }, [isOtpOpen, isLoadingOtp, onLoadingChange]) + const saveAddressesAndPhoneToProfile = async (customerId) => { + if (!basket || !customerId) return + const deliveryShipments = + basket.shipments?.filter( + (shipment) => !isPickupShipment(shipment) && shipment.shippingAddress + ) || [] + try { + if (deliveryShipments.length > 0) { + for (let i = 0; i < deliveryShipments.length; i++) { + const shipment = deliveryShipments[i] + const shipping = shipment.shippingAddress + if (!shipping) continue + + const { + address1, + address2, + city, + countryCode, + firstName, + lastName, + phone, + postalCode, + stateCode + } = shipping || {} + + await createCustomerAddress.mutateAsync({ + parameters: {customerId}, + body: { + addressId: nanoid(), + preferred: i === 0, + address1, + address2, + city, + countryCode, + firstName, + lastName, + phone, + postalCode, + stateCode + } + }) + } + } + + const phoneHome = basket.billingAddress?.phone || contactPhone + if (phoneHome) { + await updateCustomer.mutateAsync({ + parameters: {customerId}, + body: {phoneHome} + }) + } + } catch (_e) { + showError( + formatMessage({ + id: 'checkout.error.cannot_save_address', + defaultMessage: 'Could not save shipping address.' + }) + ) + } + } + const handleOtpVerification = async (otpCode) => { try { - await loginPasswordless.mutateAsync({ + const token = await loginPasswordless.mutateAsync({ pwdlessLoginToken: otpCode, register_customer: true }) + const customerId = token?.customer_id || token?.customerId + if (customerId && basket) { + await saveAddressesAndPhoneToProfile(customerId) + } + if (onRegistered) { await onRegistered(basket?.basketId) } 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 d210a8d31c..421a659aaa 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 @@ -15,6 +15,13 @@ import useAuthContext from '@salesforce/commerce-sdk-react/hooks/useAuthContext' jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket') +jest.mock( + '@salesforce/retail-react-app/app/pages/checkout-one-click/util/checkout-context', + () => ({ + useCheckout: () => ({contactPhone: ''}) + }) +) + const {AuthHelpers} = jest.requireActual('@salesforce/commerce-sdk-react') const TEST_MESSAGES = { @@ -29,12 +36,23 @@ const mockAuthHelperFunctions = { [AuthHelpers.LoginPasswordlessUser]: {mutateAsync: jest.fn()} } +const mockCreateCustomerAddress = {mutateAsync: jest.fn().mockResolvedValue({})} +const mockUpdateCustomer = {mutateAsync: jest.fn().mockResolvedValue({})} +const mockCreateCustomerPaymentInstrument = {mutateAsync: jest.fn().mockResolvedValue({})} + jest.mock('@salesforce/commerce-sdk-react', () => { const original = jest.requireActual('@salesforce/commerce-sdk-react') return { ...original, useCustomerType: jest.fn(), - useAuthHelper: jest.fn((helper) => mockAuthHelperFunctions[helper]) + useAuthHelper: jest.fn((helper) => mockAuthHelperFunctions[helper]), + useShopperCustomersMutation: jest.fn((mutationType) => { + if (mutationType === 'createCustomerAddress') return mockCreateCustomerAddress + if (mutationType === 'updateCustomer') return mockUpdateCustomer + if (mutationType === 'createCustomerPaymentInstrument') + return mockCreateCustomerPaymentInstrument + return {mutateAsync: jest.fn()} + }) } }) jest.mock('@salesforce/commerce-sdk-react/hooks/useAuthContext', () => diff --git a/packages/template-retail-react-app/app/pages/checkout-one-click/util/checkout-context.js b/packages/template-retail-react-app/app/pages/checkout-one-click/util/checkout-context.js index d1b4b0b8f0..e84a05fb8a 100644 --- a/packages/template-retail-react-app/app/pages/checkout-one-click/util/checkout-context.js +++ b/packages/template-retail-react-app/app/pages/checkout-one-click/util/checkout-context.js @@ -10,6 +10,26 @@ import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein' import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import {isPickupShipment} from '@salesforce/retail-react-app/app/utils/shipment-utils' +import { + getSessionJSONItem, + setSessionJSONItem, + clearSessionJSONItem +} from '@salesforce/retail-react-app/app/utils/utils' + +/** SessionStorage key for "checkout as guest" choice so it persists when shopper navigates away and returns */ +export const CHECKOUT_GUEST_CHOICE_STORAGE_KEY = 'sf_checkout_one_click_guest_choice' + +export const getCheckoutGuestChoiceFromStorage = () => { + return getSessionJSONItem(CHECKOUT_GUEST_CHOICE_STORAGE_KEY) === true +} + +export const setCheckoutGuestChoiceInStorage = (value) => { + if (value) { + setSessionJSONItem(CHECKOUT_GUEST_CHOICE_STORAGE_KEY, true) + } else { + clearSessionJSONItem(CHECKOUT_GUEST_CHOICE_STORAGE_KEY) + } +} const CheckoutContext = React.createContext() From 2dcb7ccd8b52f7687761c20ab960ae6b360a080d Mon Sep 17 00:00:00 2001 From: Noah Adams Date: Mon, 9 Feb 2026 14:22:06 -0800 Subject: [PATCH 09/11] logging to debug level --- packages/template-mrt-reference-app/app/ssr.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/template-mrt-reference-app/app/ssr.js b/packages/template-mrt-reference-app/app/ssr.js index 66d2cf094c..f27b1b52e4 100644 --- a/packages/template-mrt-reference-app/app/ssr.js +++ b/packages/template-mrt-reference-app/app/ssr.js @@ -363,11 +363,11 @@ const loggingMiddleware = (req, res, next) => { const envBasePathMiddleware = (req, res, next) => { const basePath = process.env.MRT_ENV_BASE_PATH - console.log(`Base path: ${basePath}`) - console.log(`Request path: ${req.url}`) + console.debug(`Base path: Base path: ${basePath}`) + console.debug(`Request path: Request path: ${req.url}`) if (basePath && (req.path.startsWith(`${basePath}/`) || req.path === basePath)) { req.url = req.url.slice(basePath.length) || '/' - console.log( + console.debug( `Base path: Rewrote ${basePath} -> Original url: ${req.originalUrl} -> New url: ${req.url}` ) } From 415686beaf2c3708ffbc3abb498212a167f39375 Mon Sep 17 00:00:00 2001 From: Jinsu Ha <91205717+hajinsuha1@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:50:01 -0500 Subject: [PATCH 10/11] Update button alignment in OtpAuth component to be centered (#3650) * Update button alignment in OtpAuth component to center for improved UX --- .../template-retail-react-app/app/components/otp-auth/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/app/components/otp-auth/index.jsx b/packages/template-retail-react-app/app/components/otp-auth/index.jsx index 0e36aceda2..5bd939a59e 100644 --- a/packages/template-retail-react-app/app/components/otp-auth/index.jsx +++ b/packages/template-retail-react-app/app/components/otp-auth/index.jsx @@ -302,7 +302,7 @@ const OtpAuth = ({ )} {/* Buttons */} - + {!hideCheckoutAsGuestButton && (