diff --git a/packages/template-retail-react-app/app/components/footer/index.jsx b/packages/template-retail-react-app/app/components/footer/index.jsx
index 9500aad913..daebaa5d3d 100644
--- a/packages/template-retail-react-app/app/components/footer/index.jsx
+++ b/packages/template-retail-react-app/app/components/footer/index.jsx
@@ -13,27 +13,23 @@ import {
SimpleGrid,
useMultiStyleConfig,
Select as ChakraSelect,
- Heading,
- Input,
- InputGroup,
- InputRightElement,
createStylesContext,
- Button,
FormControl
} from '@salesforce/retail-react-app/app/components/shared/ui'
import {useIntl} from 'react-intl'
import LinksList from '@salesforce/retail-react-app/app/components/links-list'
-import SocialIcons from '@salesforce/retail-react-app/app/components/social-icons'
+import SubscribeMarketingConsent from '@salesforce/retail-react-app/app/components/subscription'
import {HideOnDesktop, HideOnMobile} from '@salesforce/retail-react-app/app/components/responsive'
import {getPathWithLocale} from '@salesforce/retail-react-app/app/utils/url'
import LocaleText from '@salesforce/retail-react-app/app/components/locale-text'
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
import styled from '@emotion/styled'
import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constants'
+import {CONSENT_TAGS} from '@salesforce/retail-react-app/app/constants/marketing-consent'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
-const [StylesProvider, useStyles] = createStylesContext('Footer')
+const [StylesProvider] = createStylesContext('Footer')
const Footer = ({...otherProps}) => {
const styles = useMultiStyleConfig('Footer')
const intl = useIntl()
@@ -129,13 +125,13 @@ const Footer = ({...otherProps}) => {
links={makeOurCompanyLinks()}
/>
-
+
-
+
{showLocaleSelector && (
@@ -202,56 +198,6 @@ const Footer = ({...otherProps}) => {
export default Footer
-const Subscribe = ({...otherProps}) => {
- const styles = useStyles()
- const intl = useIntl()
- return (
-
-
- {intl.formatMessage({
- id: 'footer.subscribe.heading.first_to_know',
- defaultMessage: 'Be the first to know'
- })}
-
-
- {intl.formatMessage({
- id: 'footer.subscribe.description.sign_up',
- defaultMessage: 'Sign up to stay in the loop about the hottest deals'
- })}
-
-
-
-
- {/* Had to swap the following InputRightElement and Input
- to avoid the hydration error due to mismatched html between server and client side.
- This is a workaround for Lastpass plugin that automatically injects its icon for input fields.
- */}
-
-
-
-
-
-
-
-
-
- )
-}
-
const LegalLinks = ({variant}) => {
const intl = useIntl()
return (
diff --git a/packages/template-retail-react-app/app/components/footer/index.test.js b/packages/template-retail-react-app/app/components/footer/index.test.js
index 335656bcbf..8fc63383ec 100644
--- a/packages/template-retail-react-app/app/components/footer/index.test.js
+++ b/packages/template-retail-react-app/app/components/footer/index.test.js
@@ -12,7 +12,8 @@ import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-u
test('renders component', () => {
renderWithProviders()
- expect(screen.getByRole('link', {name: 'Privacy Policy'})).toBeInTheDocument()
+ const privacyLinks = screen.getAllByRole('link', {name: 'Privacy Policy'})
+ expect(privacyLinks.length).toBeGreaterThanOrEqual(1)
})
test('renders mobile version by default', () => {
@@ -20,3 +21,14 @@ test('renders mobile version by default', () => {
// This link is hidden initially, but would be shown for desktop
expect(screen.getByRole('link', {name: 'About Us', hidden: true})).toBeInTheDocument()
})
+
+test('renders SubscribeForm within Footer', () => {
+ renderWithProviders()
+ // Verify SubscribeForm renders correctly inside Footer
+ // Note: Footer renders SubscribeForm twice (mobile + desktop versions), so we use getAllBy
+ expect(screen.getByRole('heading', {name: /subscribe to stay updated/i})).toBeInTheDocument()
+ const emailInputs = screen.getAllByLabelText(/email address for newsletter/i)
+ expect(emailInputs.length).toBeGreaterThanOrEqual(1)
+ const signUpButtons = screen.getAllByRole('button', {name: /subscribe/i})
+ expect(signUpButtons.length).toBeGreaterThanOrEqual(1)
+})
diff --git a/packages/template-retail-react-app/app/components/subscription/hooks/use-email-subscription.js b/packages/template-retail-react-app/app/components/subscription/hooks/use-email-subscription.js
new file mode 100644
index 0000000000..7e3ac20f26
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/subscription/hooks/use-email-subscription.js
@@ -0,0 +1,161 @@
+/*
+ * 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 {useCallback, useMemo, useState} from 'react'
+import {
+ CONSENT_CHANNELS,
+ CONSENT_STATUS
+} from '@salesforce/retail-react-app/app/constants/marketing-consent'
+import {useMarketingConsent} from '@salesforce/retail-react-app/app/hooks/use-marketing-consent'
+import {validateEmail} from '@salesforce/retail-react-app/app/utils/subscription-validators'
+import {useIntl} from 'react-intl'
+
+/**
+ * Hook for managing email subscription form state and submission.
+ * This hook dynamically fetches all subscriptions matching a given tag and email channel,
+ * then opts the user into ALL matching subscriptions when they submit their email.
+ *
+ * Subscriptions are fetched on-demand when the user clicks submit, not on component mount.
+ *
+ * This allows marketers to configure subscriptions without code changes to the storefront UI.
+ *
+ * @param {Object} options
+ * @param {string|Array} options.tag - The consent tag(s) to filter subscriptions by (e.g., CONSENT_TAGS.EMAIL_CAPTURE or [CONSENT_TAGS.EMAIL_CAPTURE, CONSENT_TAGS.ACCOUNT])
+ * @returns {Object} Email subscription state and actions
+ * @returns {Object} return.state - Current form state
+ * @returns {string} return.state.email - Current email value
+ * @returns {boolean} return.state.isLoading - Whether submission is in progress
+ * @returns {Object} return.state.feedback - Feedback message and type
+ * @returns {string} return.state.feedback.message - User-facing message
+ * @returns {string} return.state.feedback.type - Message type ('success' | 'error')
+ * @returns {Object} return.actions - Available actions
+ * @returns {Function} return.actions.setEmail - Update email value
+ * @returns {Function} return.actions.submit - Submit the subscription
+ *
+ * @example
+ * const {state, actions} = useEmailSubscription({
+ * tag: CONSENT_TAGS.EMAIL_CAPTURE
+ * })
+ */
+export const useEmailSubscription = ({tag} = {}) => {
+ // Normalize tag to array for API call
+ const tags = useMemo(() => {
+ if (!tag) return []
+ return Array.isArray(tag) ? tag : [tag]
+ }, [tag])
+
+ const {
+ refetch: fetchSubscriptions,
+ updateSubscriptions,
+ isUpdating
+ } = useMarketingConsent({
+ tags,
+ enabled: false
+ })
+
+ const intl = useIntl()
+ const {formatMessage} = intl
+
+ const [email, setEmail] = useState('')
+ const [message, setMessage] = useState(null)
+ const [messageType, setMessageType] = useState('success')
+
+ const messages = useMemo(
+ () => ({
+ success_confirmation: formatMessage({
+ id: 'footer.success_confirmation',
+ defaultMessage: 'Thanks for subscribing!'
+ }),
+ error: {
+ enter_valid_email: formatMessage({
+ id: 'footer.error.enter_valid_email',
+ defaultMessage: 'Enter a valid email address.'
+ }),
+ generic_error: formatMessage({
+ id: 'footer.error.generic_error',
+ defaultMessage: "We couldn't process the subscription. Try again."
+ })
+ }
+ }),
+ [formatMessage]
+ )
+
+ const handleSignUp = useCallback(async () => {
+ // Validate email using the utility validator
+ const validation = validateEmail(email)
+
+ if (!validation.valid) {
+ setMessage(messages.error.enter_valid_email)
+ setMessageType('error')
+ return
+ }
+
+ try {
+ setMessage(null)
+
+ // Fetch subscriptions on-demand when submitting
+ const {data: freshSubscriptionsData} = await fetchSubscriptions()
+ const allSubscriptions = freshSubscriptionsData?.data || []
+
+ // Find matching subscriptions
+ const matchingSubs = allSubscriptions.filter((sub) => {
+ const hasEmailChannel = sub.channels?.includes(CONSENT_CHANNELS.EMAIL)
+ const hasAnyTag = tags.some((t) => sub.tags?.includes(t))
+ return hasEmailChannel && hasAnyTag
+ })
+
+ // Check if there are any matching subscriptions
+ if (matchingSubs.length === 0) {
+ const tagList = tags.join(', ')
+ console.error(
+ `[useEmailSubscription] No subscriptions found for tag(s) "${tagList}" and channel "${CONSENT_CHANNELS.EMAIL}".`
+ )
+ setMessage(messages.error.generic_error)
+ setMessageType('error')
+ return
+ }
+
+ // Build array of subscription updates for ALL matching subscriptions
+ const subscriptionUpdates = matchingSubs.map((sub) => ({
+ subscriptionId: sub.subscriptionId,
+ contactPointValue: email,
+ channel: CONSENT_CHANNELS.EMAIL,
+ status: CONSENT_STATUS.OPT_IN
+ }))
+
+ console.log(
+ `[useEmailSubscription] Opting in to ${subscriptionUpdates.length} subscription(s):`,
+ subscriptionUpdates.map((s) => s.subscriptionId)
+ )
+
+ // Submit the consent using bulk API (ShopperConsents API v1.1.3)
+ await updateSubscriptions(subscriptionUpdates)
+
+ setMessage(messages.success_confirmation)
+ setMessageType('success')
+ setEmail('')
+ } catch (err) {
+ console.error('[useEmailSubscription] Subscription error:', err)
+ setMessage(messages.error.generic_error)
+ setMessageType('error')
+ }
+ }, [email, tags, fetchSubscriptions, updateSubscriptions, messages])
+
+ return {
+ state: {
+ email,
+ isLoading: isUpdating,
+ feedback: {message, type: messageType}
+ },
+ actions: {
+ setEmail,
+ submit: handleSignUp
+ }
+ }
+}
+
+export default useEmailSubscription
diff --git a/packages/template-retail-react-app/app/components/subscription/hooks/use-email-subscription.test.js b/packages/template-retail-react-app/app/components/subscription/hooks/use-email-subscription.test.js
new file mode 100644
index 0000000000..8e08699a15
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/subscription/hooks/use-email-subscription.test.js
@@ -0,0 +1,850 @@
+/*
+ * 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 React from 'react'
+import {renderHook, act, waitFor} from '@testing-library/react'
+import {IntlProvider} from 'react-intl'
+import {useEmailSubscription} from '@salesforce/retail-react-app/app/components/subscription/hooks/use-email-subscription'
+import {useMarketingConsent} from '@salesforce/retail-react-app/app/hooks/use-marketing-consent'
+
+// Mock dependencies
+jest.mock('@salesforce/retail-react-app/app/hooks/use-marketing-consent')
+
+// Create a wrapper component that provides IntlProvider with mocked formatMessage
+const createWrapper = () => {
+ // eslint-disable-next-line react/prop-types
+ const Wrapper = ({children}) => (
+ {}}
+ >
+ {children}
+
+ )
+ Wrapper.displayName = 'IntlWrapper'
+ return Wrapper
+}
+
+// Mock formatMessage globally to ensure it returns plain strings
+const originalFormatMessage = IntlProvider.prototype.formatMessage
+beforeAll(() => {
+ // Mock formatMessage on IntlProvider
+ IntlProvider.prototype.formatMessage = function (msg) {
+ return msg.defaultMessage || msg.id
+ }
+})
+afterAll(() => {
+ IntlProvider.prototype.formatMessage = originalFormatMessage
+})
+
+// Mock console.error and console.log to avoid cluttering test output
+const originalConsoleError = console.error
+const originalConsoleLog = console.log
+beforeAll(() => {
+ console.error = jest.fn()
+ console.log = jest.fn()
+})
+afterAll(() => {
+ console.error = originalConsoleError
+ console.log = originalConsoleLog
+})
+
+describe('useEmailSubscription', () => {
+ const mockUpdateSubscriptions = jest.fn()
+ const mockGetSubscriptionsByTagAndChannel = jest.fn()
+
+ const mockMatchingSubscriptions = [
+ {
+ subscriptionId: 'weekly-newsletter',
+ channels: ['email'],
+ tags: ['email_capture']
+ },
+ {
+ subscriptionId: 'promotional-offers',
+ channels: ['email'],
+ tags: ['email_capture']
+ }
+ ]
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+
+ // Default mock implementations
+ mockGetSubscriptionsByTagAndChannel.mockReturnValue(mockMatchingSubscriptions)
+
+ useMarketingConsent.mockReturnValue({
+ data: {data: mockMatchingSubscriptions},
+ isLoading: false,
+ refetch: jest.fn().mockResolvedValue({data: {data: mockMatchingSubscriptions}}),
+ updateSubscriptions: mockUpdateSubscriptions,
+ isUpdating: false,
+ getSubscriptionsByTagAndChannel: mockGetSubscriptionsByTagAndChannel
+ })
+ })
+
+ describe('Initial state', () => {
+ test('returns correct initial state', () => {
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ expect(result.current.state.email).toBe('')
+ expect(result.current.state.isLoading).toBe(false)
+ expect(result.current.state.feedback.message).toBeNull()
+ expect(result.current.state.feedback.type).toBe('success')
+ })
+
+ test('provides setEmail and submit actions', () => {
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ expect(typeof result.current.actions.setEmail).toBe('function')
+ expect(typeof result.current.actions.submit).toBe('function')
+ })
+
+ test('passes tags to useMarketingConsent when tag is a string', () => {
+ renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ expect(useMarketingConsent).toHaveBeenCalledWith({
+ tags: ['email_capture'],
+ enabled: false
+ })
+ })
+
+ test('passes tags to useMarketingConsent when tag is an array', () => {
+ renderHook(() => useEmailSubscription({tag: ['email_capture', 'account']}), {
+ wrapper: createWrapper()
+ })
+
+ expect(useMarketingConsent).toHaveBeenCalledWith({
+ tags: ['email_capture', 'account'],
+ enabled: false
+ })
+ })
+
+ test('handles empty tag gracefully', () => {
+ mockGetSubscriptionsByTagAndChannel.mockReturnValue([])
+ const {result} = renderHook(() => useEmailSubscription({tag: undefined}), {
+ wrapper: createWrapper()
+ })
+
+ // Should not throw error with undefined tag
+ expect(result.current.state.email).toBe('')
+ expect(result.current.state.isLoading).toBe(false)
+ })
+ })
+
+ describe('setEmail action', () => {
+ test('updates email state', () => {
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('user@example.com')
+ })
+
+ expect(result.current.state.email).toBe('user@example.com')
+ })
+
+ test('allows email to be cleared', () => {
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('user@example.com')
+ })
+ expect(result.current.state.email).toBe('user@example.com')
+
+ act(() => {
+ result.current.actions.setEmail('')
+ })
+ expect(result.current.state.email).toBe('')
+ })
+ })
+
+ describe('submit action - validation', () => {
+ test('shows error for empty email', async () => {
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ expect(result.current.state.feedback.type).toBe('error')
+ expect(result.current.state.feedback.message).toBe('Enter a valid email address.')
+ expect(mockUpdateSubscriptions).not.toHaveBeenCalled()
+ })
+
+ test('shows error for invalid email format', async () => {
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('invalid-email')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ expect(result.current.state.feedback.type).toBe('error')
+ expect(result.current.state.feedback.message).toBe('Enter a valid email address.')
+ expect(mockUpdateSubscriptions).not.toHaveBeenCalled()
+ })
+
+ test('accepts valid email format', async () => {
+ mockUpdateSubscriptions.mockResolvedValue({})
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('user@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ expect(mockUpdateSubscriptions).toHaveBeenCalled()
+ })
+ })
+
+ describe('submit action - no matching subscriptions', () => {
+ test('shows error when no subscriptions match tag', async () => {
+ mockGetSubscriptionsByTagAndChannel.mockReturnValue([])
+ const {result} = renderHook(() => useEmailSubscription({tag: 'nonexistent_tag'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ expect(result.current.state.feedback.type).toBe('error')
+ expect(result.current.state.feedback.message).toBe(
+ "We couldn't process the subscription. Try again."
+ )
+ expect(mockUpdateSubscriptions).not.toHaveBeenCalled()
+ })
+
+ test('logs developer-friendly error message with single tag', async () => {
+ const mockRefetch = jest.fn().mockResolvedValue({data: {data: []}})
+
+ useMarketingConsent.mockReturnValue({
+ data: {data: []},
+ refetch: mockRefetch,
+ updateSubscriptions: mockUpdateSubscriptions,
+ isUpdating: false
+ })
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ expect(console.error).toHaveBeenCalledWith(
+ expect.stringContaining('[useEmailSubscription] No subscriptions found')
+ )
+ expect(console.error).toHaveBeenCalledWith(
+ expect.stringContaining('tag(s) "email_capture"')
+ )
+ })
+
+ test('logs developer-friendly error message with multiple tags', async () => {
+ const mockRefetch = jest.fn().mockResolvedValue({data: {data: []}})
+
+ useMarketingConsent.mockReturnValue({
+ data: {data: []},
+ refetch: mockRefetch,
+ updateSubscriptions: mockUpdateSubscriptions,
+ isUpdating: false
+ })
+ const {result} = renderHook(
+ () => useEmailSubscription({tag: ['email_capture', 'account']}),
+ {
+ wrapper: createWrapper()
+ }
+ )
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ expect(console.error).toHaveBeenCalledWith(
+ expect.stringContaining('[useEmailSubscription] No subscriptions found')
+ )
+ expect(console.error).toHaveBeenCalledWith(
+ expect.stringContaining('tag(s) "email_capture, account"')
+ )
+ })
+ })
+
+ describe('submit action - successful bulk subscription', () => {
+ test('calls updateSubscriptions with ALL matching subscriptions', async () => {
+ mockUpdateSubscriptions.mockResolvedValue({})
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ expect(mockUpdateSubscriptions).toHaveBeenCalledWith([
+ {
+ subscriptionId: 'weekly-newsletter',
+ contactPointValue: 'test@example.com',
+ channel: 'email',
+ status: 'opt_in'
+ },
+ {
+ subscriptionId: 'promotional-offers',
+ contactPointValue: 'test@example.com',
+ channel: 'email',
+ status: 'opt_in'
+ }
+ ])
+ })
+
+ test('logs subscription details to console', async () => {
+ mockUpdateSubscriptions.mockResolvedValue({})
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ expect(console.log).toHaveBeenCalledWith(
+ expect.stringContaining('[useEmailSubscription] Opting in to 2 subscription(s)'),
+ expect.any(Array)
+ )
+ })
+
+ test('shows success message after successful submission', async () => {
+ mockUpdateSubscriptions.mockResolvedValue({})
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ await waitFor(() => {
+ expect(result.current.state.feedback.type).toBe('success')
+ expect(result.current.state.feedback.message).toBe('Thanks for subscribing!')
+ })
+ })
+
+ test('clears email field after successful submission', async () => {
+ mockUpdateSubscriptions.mockResolvedValue({})
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ await waitFor(() => {
+ expect(result.current.state.email).toBe('')
+ })
+ })
+
+ test('handles single subscription in bulk update', async () => {
+ const mockRefetch = jest
+ .fn()
+ .mockResolvedValue({data: {data: [mockMatchingSubscriptions[0]]}})
+ mockUpdateSubscriptions.mockResolvedValue({})
+
+ useMarketingConsent.mockReturnValue({
+ data: {data: [mockMatchingSubscriptions[0]]},
+ refetch: mockRefetch,
+ updateSubscriptions: mockUpdateSubscriptions,
+ isUpdating: false
+ })
+
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ expect(mockUpdateSubscriptions).toHaveBeenCalledWith([
+ {
+ subscriptionId: 'weekly-newsletter',
+ contactPointValue: 'test@example.com',
+ channel: 'email',
+ status: 'opt_in'
+ }
+ ])
+ })
+ })
+
+ describe('submit action - failed subscription', () => {
+ test('shows generic error message when API call fails', async () => {
+ mockUpdateSubscriptions.mockRejectedValue(new Error('API Error'))
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ await waitFor(() => {
+ expect(result.current.state.feedback.type).toBe('error')
+ expect(result.current.state.feedback.message).toBe(
+ "We couldn't process the subscription. Try again."
+ )
+ })
+ })
+
+ test('does not clear email field on failure', async () => {
+ mockUpdateSubscriptions.mockRejectedValue(new Error('API Error'))
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ await waitFor(() => {
+ expect(result.current.state.email).toBe('test@example.com')
+ })
+ })
+
+ test('logs error to console when submission fails', async () => {
+ const mockError = new Error('Network error')
+ mockUpdateSubscriptions.mockRejectedValue(mockError)
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ await waitFor(() => {
+ expect(console.error).toHaveBeenCalledWith(
+ '[useEmailSubscription] Subscription error:',
+ mockError
+ )
+ })
+ })
+ })
+
+ describe('Loading states', () => {
+ test('reflects isUpdating state from useMarketingConsent', () => {
+ useMarketingConsent.mockReturnValue({
+ data: {data: mockMatchingSubscriptions},
+ isLoading: false,
+ updateSubscriptions: mockUpdateSubscriptions,
+ isUpdating: true,
+ getSubscriptionsByTagAndChannel: mockGetSubscriptionsByTagAndChannel
+ })
+
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ expect(result.current.state.isLoading).toBe(true)
+ })
+
+ test('isLoading is false when not updating', () => {
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ expect(result.current.state.isLoading).toBe(false)
+ })
+ })
+
+ describe('Tag filtering', () => {
+ test('filters subscriptions by single tag on submit', async () => {
+ const mockCheckoutSubscriptions = [
+ {
+ subscriptionId: 'checkout-updates',
+ channels: ['email'],
+ tags: ['checkout']
+ }
+ ]
+
+ const mockRefetch = jest
+ .fn()
+ .mockResolvedValue({data: {data: mockCheckoutSubscriptions}})
+ mockUpdateSubscriptions.mockResolvedValue({})
+
+ useMarketingConsent.mockReturnValue({
+ data: {data: mockCheckoutSubscriptions},
+ refetch: mockRefetch,
+ updateSubscriptions: mockUpdateSubscriptions,
+ isUpdating: false
+ })
+
+ const {result} = renderHook(() => useEmailSubscription({tag: 'checkout'}), {
+ wrapper: createWrapper()
+ })
+
+ expect(useMarketingConsent).toHaveBeenCalledWith({tags: ['checkout'], enabled: false})
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ // Should call updateSubscriptions with the checkout subscription
+ expect(mockUpdateSubscriptions).toHaveBeenCalledWith([
+ {
+ subscriptionId: 'checkout-updates',
+ contactPointValue: 'test@example.com',
+ channel: 'email',
+ status: 'opt_in'
+ }
+ ])
+ })
+
+ test('passes multiple tags to useMarketingConsent with enabled=false', () => {
+ renderHook(() => useEmailSubscription({tag: ['email_capture', 'account']}), {
+ wrapper: createWrapper()
+ })
+
+ expect(useMarketingConsent).toHaveBeenCalledWith({
+ tags: ['email_capture', 'account'],
+ enabled: false
+ })
+ })
+
+ test('filters subscriptions with actual API format on submit', async () => {
+ // Test that the filtering logic works with the actual API format
+ const mockApiFormatSubscriptions = [
+ {
+ subscriptionId: 'marketing-email',
+ channels: ['email'],
+ tags: ['email_capture']
+ },
+ {
+ subscriptionId: 'account-newsletter',
+ channels: ['email'],
+ tags: ['account']
+ }
+ ]
+
+ const mockRefetch = jest
+ .fn()
+ .mockResolvedValue({data: {data: mockApiFormatSubscriptions}})
+ mockUpdateSubscriptions.mockResolvedValue({})
+
+ useMarketingConsent.mockReturnValue({
+ data: {data: mockApiFormatSubscriptions},
+ refetch: mockRefetch,
+ updateSubscriptions: mockUpdateSubscriptions,
+ isUpdating: false
+ })
+
+ const {result} = renderHook(
+ () => useEmailSubscription({tag: ['email_capture', 'account']}),
+ {
+ wrapper: createWrapper()
+ }
+ )
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ // Should call updateSubscriptions with both matching subscriptions
+ expect(mockUpdateSubscriptions).toHaveBeenCalledWith([
+ {
+ subscriptionId: 'marketing-email',
+ contactPointValue: 'test@example.com',
+ channel: 'email',
+ status: 'opt_in'
+ },
+ {
+ subscriptionId: 'account-newsletter',
+ contactPointValue: 'test@example.com',
+ channel: 'email',
+ status: 'opt_in'
+ }
+ ])
+ })
+
+ test('matches subscription with multiple tags', async () => {
+ // Test that subscriptions with multiple tags are matched correctly
+ const mockMultipleTagsSubscriptions = [
+ {
+ subscriptionId: 'marketing-email',
+ channels: ['email'],
+ tags: ['email_capture', 'account', 'checkout'] // Multiple tags
+ }
+ ]
+
+ const mockRefetch = jest
+ .fn()
+ .mockResolvedValue({data: {data: mockMultipleTagsSubscriptions}})
+ mockUpdateSubscriptions.mockResolvedValue({})
+
+ useMarketingConsent.mockReturnValue({
+ data: {data: mockMultipleTagsSubscriptions},
+ refetch: mockRefetch,
+ updateSubscriptions: mockUpdateSubscriptions,
+ isUpdating: false
+ })
+
+ const {result} = renderHook(
+ () => useEmailSubscription({tag: ['email_capture', 'account']}),
+ {
+ wrapper: createWrapper()
+ }
+ )
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ // Should match because subscription has both tags
+ expect(mockUpdateSubscriptions).toHaveBeenCalledWith([
+ {
+ subscriptionId: 'marketing-email',
+ contactPointValue: 'test@example.com',
+ channel: 'email',
+ status: 'opt_in'
+ }
+ ])
+ })
+
+ test('filters out non-email channel subscriptions on submit', async () => {
+ // Test that SMS and other channels are excluded
+ const mockMixedChannelSubscriptions = [
+ {
+ subscriptionId: 'email-newsletter',
+ channels: ['email'],
+ tags: ['email_capture']
+ },
+ {
+ subscriptionId: 'sms-alerts',
+ channels: ['sms'],
+ tags: ['email_capture']
+ },
+ {
+ subscriptionId: 'push-notifications',
+ channels: ['push'],
+ tags: ['email_capture']
+ }
+ ]
+
+ const mockRefetch = jest
+ .fn()
+ .mockResolvedValue({data: {data: mockMixedChannelSubscriptions}})
+ mockUpdateSubscriptions.mockResolvedValue({})
+
+ useMarketingConsent.mockReturnValue({
+ data: {data: mockMixedChannelSubscriptions},
+ refetch: mockRefetch,
+ updateSubscriptions: mockUpdateSubscriptions,
+ isUpdating: false
+ })
+
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ // Should only include the email subscription, not SMS or push
+ expect(mockUpdateSubscriptions).toHaveBeenCalledWith([
+ {
+ subscriptionId: 'email-newsletter',
+ contactPointValue: 'test@example.com',
+ channel: 'email',
+ status: 'opt_in'
+ }
+ ])
+ })
+
+ test('uses updated tag when rerendered', () => {
+ const {rerender} = renderHook(({tag}) => useEmailSubscription({tag}), {
+ initialProps: {tag: 'email_capture'},
+ wrapper: createWrapper()
+ })
+
+ expect(useMarketingConsent).toHaveBeenCalledWith({
+ tags: ['email_capture'],
+ enabled: false
+ })
+
+ // Change the tag
+ rerender({tag: 'registration'})
+
+ expect(useMarketingConsent).toHaveBeenCalledWith({
+ tags: ['registration'],
+ enabled: false
+ })
+ })
+ })
+
+ describe('Edge cases', () => {
+ test('handles undefined subscriptions data', () => {
+ const mockRefetch = jest.fn().mockResolvedValue({data: undefined})
+ useMarketingConsent.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ refetch: mockRefetch,
+ updateSubscriptions: mockUpdateSubscriptions,
+ isUpdating: false,
+ getSubscriptionsByTagAndChannel: mockGetSubscriptionsByTagAndChannel
+ })
+ mockGetSubscriptionsByTagAndChannel.mockReturnValue([])
+
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ // Should not throw an error
+ expect(result.current.state.email).toBe('')
+ })
+
+ test('clears previous feedback messages before submission', async () => {
+ mockUpdateSubscriptions.mockResolvedValue({})
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ // First submission with error
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+ expect(result.current.state.feedback.message).toBeTruthy()
+
+ // Second submission with valid email
+ act(() => {
+ result.current.actions.setEmail('test@example.com')
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ // Should show success, not previous error
+ await waitFor(() => {
+ expect(result.current.state.feedback.type).toBe('success')
+ })
+ })
+
+ test('accepts various valid email formats', async () => {
+ mockUpdateSubscriptions.mockResolvedValue({})
+ const validEmails = [
+ 'simple@example.com',
+ 'user+tag@example.com',
+ 'first.last@example.com',
+ 'user123@example456.com',
+ 'user@subdomain.example.com'
+ ]
+
+ for (const email of validEmails) {
+ const {result} = renderHook(() => useEmailSubscription({tag: 'email_capture'}), {
+ wrapper: createWrapper()
+ })
+
+ act(() => {
+ result.current.actions.setEmail(email)
+ })
+
+ await act(async () => {
+ await result.current.actions.submit()
+ })
+
+ await waitFor(() => {
+ expect(mockUpdateSubscriptions).toHaveBeenCalled()
+ })
+
+ mockUpdateSubscriptions.mockClear()
+ }
+ })
+ })
+})
diff --git a/packages/template-retail-react-app/app/components/subscription/index.js b/packages/template-retail-react-app/app/components/subscription/index.js
new file mode 100644
index 0000000000..44cd23385f
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/subscription/index.js
@@ -0,0 +1,7 @@
+/*
+ * 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
+ */
+export {default} from './subscribe-marketing-consent'
diff --git a/packages/template-retail-react-app/app/components/subscription/subscribe-form.jsx b/packages/template-retail-react-app/app/components/subscription/subscribe-form.jsx
new file mode 100644
index 0000000000..f1dc423cdf
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/subscription/subscribe-form.jsx
@@ -0,0 +1,155 @@
+/*
+ * 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 React from 'react'
+import PropTypes from 'prop-types'
+import {
+ Box,
+ Text,
+ Heading,
+ Input,
+ InputGroup,
+ InputRightElement,
+ Button,
+ Alert,
+ AlertIcon,
+ AlertDescription,
+ Link,
+ useMultiStyleConfig
+} from '@salesforce/retail-react-app/app/components/shared/ui'
+import {useIntl, FormattedMessage} from 'react-intl'
+import SocialIcons from '@salesforce/retail-react-app/app/components/social-icons'
+
+const SubscribeForm = ({subscription, ...otherProps}) => {
+ // Use SubscribeForm's own theme config instead of Footer's context
+ const subscribeFormStyles = useMultiStyleConfig('SubscribeForm')
+
+ // Map SubscribeForm theme parts to Footer's expected structure
+ const styles = {
+ subscribe: subscribeFormStyles.container,
+ subscribeHeading: subscribeFormStyles.heading,
+ subscribeMessage: subscribeFormStyles.message,
+ subscribeField: subscribeFormStyles.field,
+ subscribeButtonContainer: subscribeFormStyles.buttonContainer,
+ socialIcons: subscribeFormStyles.socialIcons,
+ subscribeDisclaimer: subscribeFormStyles.disclaimer
+ }
+
+ // Helper to create themed links for FormattedMessage
+ const createLink = (chunks) => (
+
+ {chunks}
+
+ )
+
+ const intl = useIntl()
+ const {state, actions} = subscription
+
+ const messages = {
+ heading: intl.formatMessage({
+ id: 'footer.subscribe.heading.stay_updated',
+ defaultMessage: 'Subscribe to Stay Updated'
+ }),
+ description: intl.formatMessage({
+ id: 'footer.subscribe.description.sign_up',
+ defaultMessage: 'Be the first to know about latest offers, news, tips, and more.'
+ }),
+ emailAriaLabel: intl.formatMessage({
+ id: 'footer.subscribe.email.assistive_msg',
+ defaultMessage: 'Email address for newsletter'
+ }),
+ buttonSignUp: intl.formatMessage({
+ id: 'footer.subscribe.button.sign_up',
+ defaultMessage: 'Subscribe'
+ }),
+ emailPlaceholder: intl.formatMessage({
+ id: 'footer.subscribe.email.placeholder_text',
+ defaultMessage: 'Enter your email address...'
+ })
+ }
+
+ return (
+
+
+ {messages.heading}
+
+ {messages.description}
+
+ {state?.feedback?.message && (
+
+
+ {state.feedback.message}
+
+ )}
+
+
+
+ {/* Had to swap the following InputRightElement and Input
+ to avoid the hydration error due to mismatched html between server and client side.
+ This is a workaround for Lastpass plugin that automatically injects its icon for input fields.
+ */}
+
+
+
+ actions?.setEmail?.(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !state?.isLoading) {
+ actions?.submit?.()
+ }
+ }}
+ disabled={state?.isLoading}
+ id="subscribe-email"
+ {...styles.subscribeField}
+ />
+
+
+
+
+
+
+
+
+
+ )
+}
+
+SubscribeForm.propTypes = {
+ subscription: PropTypes.shape({
+ state: PropTypes.shape({
+ email: PropTypes.string,
+ isLoading: PropTypes.bool,
+ feedback: PropTypes.shape({
+ message: PropTypes.string,
+ type: PropTypes.oneOf(['success', 'error'])
+ })
+ }),
+ actions: PropTypes.shape({
+ setEmail: PropTypes.func,
+ submit: PropTypes.func
+ })
+ }).isRequired
+}
+
+export default SubscribeForm
diff --git a/packages/template-retail-react-app/app/components/subscription/subscribe-form.test.jsx b/packages/template-retail-react-app/app/components/subscription/subscribe-form.test.jsx
new file mode 100644
index 0000000000..402a4ca17c
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/subscription/subscribe-form.test.jsx
@@ -0,0 +1,262 @@
+/*
+ * 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 React from 'react'
+import {screen, waitFor} from '@testing-library/react'
+import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
+import SubscribeForm from '@salesforce/retail-react-app/app/components/subscription/subscribe-form'
+
+const mockSubscriptionState = {
+ state: {
+ email: '',
+ isLoading: false,
+ feedback: {message: null, type: 'success'}
+ },
+ actions: {
+ setEmail: jest.fn(),
+ submit: jest.fn()
+ }
+}
+
+describe('SubscribeForm', () => {
+ test('renders component with all essential elements', () => {
+ renderWithProviders()
+
+ // Check heading
+ expect(
+ screen.getByRole('heading', {name: /subscribe to stay updated/i})
+ ).toBeInTheDocument()
+
+ // Check description
+ expect(screen.getByText(/be the first to know about latest/i)).toBeInTheDocument()
+
+ // Check email input
+ const emailInput = screen.getByLabelText(/email address for newsletter/i)
+ expect(emailInput).toBeInTheDocument()
+ expect(emailInput).toHaveAttribute('type', 'email')
+ expect(emailInput).toHaveAttribute('placeholder', 'Enter your email address...')
+
+ // Check sign up button
+ expect(screen.getByRole('button', {name: /subscribe/i})).toBeInTheDocument()
+
+ // Check disclaimer text with links
+ expect(screen.getByText(/by submitting this, i agree to the/i)).toBeInTheDocument()
+ expect(screen.getByRole('link', {name: /terms & conditions/i})).toBeInTheDocument()
+ expect(screen.getByRole('link', {name: /privacy policy/i})).toBeInTheDocument()
+ })
+
+ test('renders independently without Footer context', () => {
+ // This is the key test - SubscribeForm should render without being wrapped in Footer
+ const {container} = renderWithProviders(
+
+ )
+
+ // Should render without errors
+ expect(container).toBeInTheDocument()
+ expect(
+ screen.getByRole('heading', {name: /subscribe to stay updated/i})
+ ).toBeInTheDocument()
+ })
+
+ test('applies theme styles correctly', () => {
+ renderWithProviders()
+
+ // The component should apply SubscribeForm theme styles
+ // We can't directly test Chakra styles, but we can verify the component structure
+ const heading = screen.getByRole('heading', {name: /subscribe to stay updated/i})
+ expect(heading.tagName).toBe('H2')
+
+ // Verify the input and button exist within an input group
+ const emailInput = screen.getByLabelText(/email address for newsletter/i)
+ const submitButton = screen.getByRole('button', {name: /subscribe/i})
+
+ // Verify both elements are in the document and properly structured
+ expect(emailInput).toBeInTheDocument()
+ expect(submitButton).toBeInTheDocument()
+ expect(emailInput.parentElement).toBeTruthy()
+ })
+
+ test('calls setEmail when user types in email field', async () => {
+ const mockSetEmail = jest.fn()
+ const subscription = {
+ ...mockSubscriptionState,
+ actions: {
+ ...mockSubscriptionState.actions,
+ setEmail: mockSetEmail
+ }
+ }
+
+ const {user} = renderWithProviders()
+ const emailInput = screen.getByLabelText(/email address for newsletter/i)
+
+ await user.type(emailInput, 'test@example.com')
+
+ await waitFor(() => {
+ expect(mockSetEmail).toHaveBeenCalled()
+ })
+ })
+
+ test('calls submit when sign up button is clicked', async () => {
+ const mockSubmit = jest.fn()
+ const subscription = {
+ ...mockSubscriptionState,
+ actions: {
+ ...mockSubscriptionState.actions,
+ submit: mockSubmit
+ }
+ }
+
+ const {user} = renderWithProviders()
+ const submitButton = screen.getByRole('button', {name: /subscribe/i})
+
+ await user.click(submitButton)
+
+ expect(mockSubmit).toHaveBeenCalledTimes(1)
+ })
+
+ test('calls submit when Enter key is pressed in email field', async () => {
+ const mockSubmit = jest.fn()
+ const subscription = {
+ ...mockSubscriptionState,
+ actions: {
+ ...mockSubscriptionState.actions,
+ submit: mockSubmit
+ }
+ }
+
+ const {user} = renderWithProviders()
+ const emailInput = screen.getByLabelText(/email address for newsletter/i)
+
+ await user.type(emailInput, 'test@example.com')
+ await user.keyboard('{Enter}')
+
+ expect(mockSubmit).toHaveBeenCalledTimes(1)
+ })
+
+ test('does not call submit when Enter is pressed while loading', async () => {
+ const mockSubmit = jest.fn()
+ const subscription = {
+ state: {
+ ...mockSubscriptionState.state,
+ isLoading: true
+ },
+ actions: {
+ ...mockSubscriptionState.actions,
+ submit: mockSubmit
+ }
+ }
+
+ const {user} = renderWithProviders()
+ const emailInput = screen.getByLabelText(/email address for newsletter/i)
+
+ await user.type(emailInput, 'test@example.com')
+ await user.keyboard('{Enter}')
+
+ expect(mockSubmit).not.toHaveBeenCalled()
+ })
+
+ test('displays loading state on button', () => {
+ const subscription = {
+ state: {
+ ...mockSubscriptionState.state,
+ isLoading: true
+ },
+ actions: mockSubscriptionState.actions
+ }
+
+ renderWithProviders()
+ const submitButton = screen.getByRole('button', {name: /subscribe/i})
+
+ expect(submitButton).toBeDisabled()
+ })
+
+ test('disables email input when loading', () => {
+ const subscription = {
+ state: {
+ ...mockSubscriptionState.state,
+ isLoading: true
+ },
+ actions: mockSubscriptionState.actions
+ }
+
+ renderWithProviders()
+ const emailInput = screen.getByLabelText(/email address for newsletter/i)
+
+ expect(emailInput).toBeDisabled()
+ })
+
+ test('displays success feedback message', () => {
+ const subscription = {
+ state: {
+ ...mockSubscriptionState.state,
+ feedback: {
+ type: 'success',
+ message: 'Successfully subscribed!'
+ }
+ },
+ actions: mockSubscriptionState.actions
+ }
+
+ renderWithProviders()
+
+ expect(screen.getByText(/successfully subscribed!/i)).toBeInTheDocument()
+ expect(screen.getByRole('alert')).toHaveAttribute('data-status', 'success')
+ })
+
+ test('displays error feedback message', () => {
+ const subscription = {
+ state: {
+ ...mockSubscriptionState.state,
+ feedback: {
+ type: 'error',
+ message: 'Subscription failed. Please try again.'
+ }
+ },
+ actions: mockSubscriptionState.actions
+ }
+
+ renderWithProviders()
+
+ expect(screen.getByText(/subscription failed. please try again./i)).toBeInTheDocument()
+ expect(screen.getByRole('alert')).toHaveAttribute('data-status', 'error')
+ })
+
+ test('displays email value from state', () => {
+ const subscription = {
+ state: {
+ ...mockSubscriptionState.state,
+ email: 'prefilled@example.com'
+ },
+ actions: mockSubscriptionState.actions
+ }
+
+ renderWithProviders()
+ const emailInput = screen.getByLabelText(/email address for newsletter/i)
+
+ expect(emailInput).toHaveValue('prefilled@example.com')
+ })
+
+ test('accepts custom props via otherProps', () => {
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByTestId('custom-subscribe-form')).toBeInTheDocument()
+ })
+
+ test('form is interactive when not fetching and not loading', () => {
+ renderWithProviders()
+
+ const emailInput = screen.getByLabelText(/email address for newsletter/i)
+ const submitButton = screen.getByRole('button', {name: /subscribe/i})
+
+ expect(emailInput).not.toBeDisabled()
+ expect(submitButton).not.toBeDisabled()
+ })
+})
diff --git a/packages/template-retail-react-app/app/components/subscription/subscribe-marketing-consent.jsx b/packages/template-retail-react-app/app/components/subscription/subscribe-marketing-consent.jsx
new file mode 100644
index 0000000000..dd3ea291c1
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/subscription/subscribe-marketing-consent.jsx
@@ -0,0 +1,44 @@
+/*
+ * 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 React from 'react'
+import PropTypes from 'prop-types'
+import SubscribeForm from '@salesforce/retail-react-app/app/components/subscription/subscribe-form'
+import {useEmailSubscription} from '@salesforce/retail-react-app/app/components/subscription/hooks/use-email-subscription'
+import {CONSENT_TAGS} from '@salesforce/retail-react-app/app/constants/marketing-consent'
+
+/**
+ * Marketing consent subscription component for email subscriptions.
+ * This component dynamically fetches all subscriptions matching a given consent tag
+ * and email channel, then opts the user into ALL matching subscriptions.
+ *
+ * This allows marketers to configure subscriptions without code changes to the storefront UI.
+ *
+ * Subscriptions are fetched only when the user submits the form, keeping the initial page load lightweight.
+ *
+ * @param {Object} props
+ * @param {string|Array} props.tag - The consent tag(s) to filter subscriptions by (e.g., CONSENT_TAGS.EMAIL_CAPTURE or [CONSENT_TAGS.EMAIL_CAPTURE, CONSENT_TAGS.ACCOUNT])
+ *
+ * @example
+ * // In footer
+ *
+ *
+ * // Multiple tags
+ *
+ *
+ * // On registration page
+ *
+ */
+const SubscribeMarketingConsent = ({tag = CONSENT_TAGS.EMAIL_CAPTURE, ...props}) => {
+ const {state, actions} = useEmailSubscription({tag})
+ return
+}
+
+SubscribeMarketingConsent.propTypes = {
+ tag: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)])
+}
+
+export default SubscribeMarketingConsent
diff --git a/packages/template-retail-react-app/app/constants/marketing-consent.js b/packages/template-retail-react-app/app/constants/marketing-consent.js
new file mode 100644
index 0000000000..7763b4d7db
--- /dev/null
+++ b/packages/template-retail-react-app/app/constants/marketing-consent.js
@@ -0,0 +1,27 @@
+/*
+ * 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
+ */
+
+// Marketing consent status constants
+export const CONSENT_STATUS = {
+ OPT_IN: 'opt_in',
+ OPT_OUT: 'opt_out'
+}
+
+// Marketing consent channels, as configured by an administrator.
+export const CONSENT_CHANNELS = {
+ EMAIL: 'email',
+ SMS: 'sms'
+}
+
+// Marketing consent tags, as configured by an administrator.
+// These tags allow you to organize subscriptions by where they appear in the UI
+export const CONSENT_TAGS = {
+ ACCOUNT: 'account',
+ CHECKOUT: 'checkout',
+ REGISTRATION: 'registration',
+ EMAIL_CAPTURE: 'email_capture'
+}
diff --git a/packages/template-retail-react-app/app/hooks/index.js b/packages/template-retail-react-app/app/hooks/index.js
index 62ca6fdd56..999fe5ccba 100644
--- a/packages/template-retail-react-app/app/hooks/index.js
+++ b/packages/template-retail-react-app/app/hooks/index.js
@@ -17,3 +17,4 @@ export {useDerivedProduct} from '@salesforce/retail-react-app/app/hooks/use-deri
export {useCurrency} from '@salesforce/retail-react-app/app/hooks/use-currency'
export {useRuleBasedBonusProducts} from '@salesforce/retail-react-app/app/hooks/use-rule-based-bonus-products'
export {useControlledVariations} from '@salesforce/retail-react-app/app/hooks/use-controlled-variations'
+export {useMarketingConsent} from '@salesforce/retail-react-app/app/hooks/use-marketing-consent'
diff --git a/packages/template-retail-react-app/app/hooks/use-marketing-consent.js b/packages/template-retail-react-app/app/hooks/use-marketing-consent.js
new file mode 100644
index 0000000000..92f970dc1f
--- /dev/null
+++ b/packages/template-retail-react-app/app/hooks/use-marketing-consent.js
@@ -0,0 +1,251 @@
+/*
+ * 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 {
+ useSubscriptions,
+ useShopperConsentsMutation,
+ ShopperConsentsMutations
+} from '@salesforce/commerce-sdk-react'
+import {useEffect} from 'react'
+
+/**
+ * A hook for managing customer marketing consent subscriptions.
+ * Provides functionality to retrieve consent preferences and update them individually or in bulk.
+ *
+ * @param {Object} options - Configuration options
+ * @param {boolean} options.enabled - Whether to enable the subscriptions query (defaults to true)
+ * @param {Array} options.tags - Optional array of tags to filter subscriptions (all included)
+ * @param {string} options.expand - Optional expand parameter (e.g., 'consentStatus' for logged-in user status on profile page)
+ * @returns {Object} Object containing:
+ * - data: The consent subscription data
+ * - isLoading: Whether the initial query is loading (only true when enabled=true on mount)
+ * - isFetching: Whether the query is fetching (true during refetch() calls)
+ * - error: Any error from the query
+ * - refetch: Function to manually fetch subscriptions (for use with enabled=false)
+ * - updateSubscription: Function to update a single subscription
+ * - updateSubscriptions: Function to update multiple subscriptions
+ * - isUpdating: Whether any operation is in progress (fetching OR updating)
+ * - updateError: Any error from update mutations
+ * - getSubscriptionStatus: Helper to get status for a specific subscription and channel
+ * - hasChannel: Helper to check if a subscription has a specific channel
+ * - getSubscriptionsByTagAndChannel: Helper to filter subscriptions by tag and channel
+ *
+ * @example
+ * // Basic usage
+ * const {
+ * data,
+ * updateSubscription,
+ * updateSubscriptions,
+ * getSubscriptionStatus,
+ * hasChannel
+ * } = useMarketingConsent()
+ *
+ * // Update single subscription
+ * await updateSubscription({
+ * subscriptionId: 'marketing-email',
+ * channel: 'email',
+ * status: 'opt_in',
+ * contactPointValue: 'customer@example.com'
+ * })
+ *
+ * // Update multiple subscriptions
+ * await updateSubscriptions([
+ * {
+ * subscriptionId: 'marketing-email',
+ * channel: 'email',
+ * status: 'opt_in',
+ * contactPointValue: 'customer@example.com'
+ * },
+ * {
+ * subscriptionId: 'marketing-sms',
+ * channel: 'sms',
+ * status: 'opt_out',
+ * contactPointValue: '+15551234567'
+ * }
+ * ])
+ *
+ * // Check subscription status
+ * const isOptedIn = getSubscriptionStatus('marketing-email', 'email') === 'opt_in'
+ *
+ * // Check if subscription has a channel
+ * const hasEmailChannel = hasChannel('marketing-email', 'email')
+ */
+export const useMarketingConsent = ({enabled = true, tags = [], expand} = {}) => {
+ // Query hook to get current subscriptions
+ const subscriptionsQuery = useSubscriptions(
+ {
+ parameters: {
+ ...(tags.length > 0 && {tags: tags.join(',')}),
+ ...(expand && {expand})
+ }
+ },
+ {
+ enabled
+ }
+ )
+
+ // Mutation hooks for updating subscriptions
+ const updateSubscriptionMutation = useShopperConsentsMutation(
+ ShopperConsentsMutations.UpdateSubscription
+ )
+ const updateSubscriptionsMutation = useShopperConsentsMutation(
+ ShopperConsentsMutations.UpdateSubscriptions
+ )
+
+ // Log warning if no subscriptions found after initial fetch
+ useEffect(() => {
+ // Only check after the query has completed loading
+ if (!subscriptionsQuery.isLoading && enabled) {
+ const subscriptions = subscriptionsQuery.data?.data || []
+ const hasError = subscriptionsQuery.error
+
+ // Warn if there's an error or no subscriptions found
+ if (hasError || subscriptions.length === 0) {
+ const tagFilter = tags.length > 0 ? ` (filtered by tags: ${tags.join(', ')})` : ''
+ console.warn(
+ `[useMarketingConsent] Marketing Consent feature was enabled, but no subscriptions were found${tagFilter}.`
+ )
+ if (hasError) {
+ console.error('[useMarketingConsent] API Error:', subscriptionsQuery.error)
+ }
+ }
+ }
+ }, [
+ subscriptionsQuery.isLoading,
+ subscriptionsQuery.data,
+ subscriptionsQuery.error,
+ enabled,
+ tags
+ ])
+
+ const subscriptions = subscriptionsQuery.data?.data || []
+
+ /**
+ * Get the opt-in/opt-out status for a specific subscription and channel
+ * @param {string} subscriptionId - The subscription ID
+ * @param {string} channel - The channel type ('email', 'sms', etc.)
+ * @param {string} contactPointValue - Optional contact point (email/phone) to check status for specific contact
+ * @returns {string|null} The consent status ('opt_in', 'opt_out') or null if not found
+ */
+ const getSubscriptionStatus = (subscriptionId, channel, contactPointValue) => {
+ const subscription = subscriptions.find((sub) => sub.subscriptionId === subscriptionId)
+ if (!subscription) return null
+
+ // If expanded with status data and contactPointValue is provided, use the actual status
+ if (subscription.status && contactPointValue) {
+ const statusEntry = subscription.status.find(
+ (s) => s.channel === channel && s.contactPointValue === contactPointValue
+ )
+ if (statusEntry) {
+ return statusEntry.status
+ }
+ }
+
+ // Fall back to defaultStatus if available (for logged-in users without explicit status)
+ if (subscription.defaultStatus) {
+ return subscription.defaultStatus
+ }
+
+ // Legacy fallback: check if channel exists in channels array
+ const hasChannelInArray = subscription.channels && subscription.channels.includes(channel)
+ return hasChannelInArray ? 'opt_in' : 'opt_out'
+ }
+
+ /**
+ * Check if a subscription includes a specific channel
+ * @param {string} subscriptionId - The subscription ID
+ * @param {string} channel - The channel type ('email', 'sms', etc.)
+ * @returns {boolean} True if the subscription includes the channel
+ */
+ const hasChannel = (subscriptionId, channel) => {
+ const subscription = subscriptions.find((sub) => sub.subscriptionId === subscriptionId)
+ return subscription?.channels?.includes(channel) || false
+ }
+
+ /**
+ * Get all subscriptions for a specific contact point value (email or phone)
+ * @param {string} contactPointValue - The email or phone number
+ * @returns {Array} Array of subscriptions matching the contact point
+ */
+ const getSubscriptionsByContact = (contactPointValue) => {
+ return subscriptions.filter((sub) => sub.contactPointValue === contactPointValue)
+ }
+
+ /**
+ * Get subscriptions filtered by tag and channel
+ * Useful for finding all subscriptions that should be opted into for a specific UI location
+ * @param {string} tag - The tag to filter by (e.g., 'homepage_banner', 'footer')
+ * @param {string} channel - The channel type ('email', 'sms', etc.)
+ * @returns {Array} Array of subscription objects matching the tag and channel
+ */
+ const getSubscriptionsByTagAndChannel = (tag, channel) => {
+ return subscriptions.filter((sub) => {
+ const hasTag = sub.tags && sub.tags.includes(tag)
+ const hasChannelMatch = sub.channels && sub.channels.includes(channel)
+ return hasTag && hasChannelMatch
+ })
+ }
+
+ /**
+ * Update a single consent subscription
+ * @param {Object} subscriptionData - The subscription data
+ * @param {string} subscriptionData.subscriptionId - The subscription ID
+ * @param {string} subscriptionData.channel - The channel type ('email', 'sms', etc.)
+ * @param {string} subscriptionData.status - The consent status ('opt_in', 'opt_out')
+ * @param {string} subscriptionData.contactPointValue - The email or phone number
+ * @returns {Promise} Promise resolving to the mutation result
+ */
+ const updateSubscription = async (subscriptionData) => {
+ return updateSubscriptionMutation.mutateAsync({
+ parameters: {},
+ body: subscriptionData
+ })
+ }
+
+ /**
+ * Update multiple consent subscriptions in bulk
+ * @param {Array