Skip to content

Commit 1ec047a

Browse files
@W-18999015 footer component updated for shopper consents email marketing subscription hooks (#3471)
* footer component updated. button handler added. labels updated. * handler to query shopper-consents SCAPI using &tags=email_capture, and also to opt-in for all matching subscriptions using the input email address as the contactPointValue.
1 parent 0104a11 commit 1ec047a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+3254
-222
lines changed

packages/template-retail-react-app/app/components/footer/index.jsx

Lines changed: 5 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -13,27 +13,23 @@ import {
1313
SimpleGrid,
1414
useMultiStyleConfig,
1515
Select as ChakraSelect,
16-
Heading,
17-
Input,
18-
InputGroup,
19-
InputRightElement,
2016
createStylesContext,
21-
Button,
2217
FormControl
2318
} from '@salesforce/retail-react-app/app/components/shared/ui'
2419
import {useIntl} from 'react-intl'
2520

2621
import LinksList from '@salesforce/retail-react-app/app/components/links-list'
27-
import SocialIcons from '@salesforce/retail-react-app/app/components/social-icons'
22+
import SubscribeMarketingConsent from '@salesforce/retail-react-app/app/components/subscription'
2823
import {HideOnDesktop, HideOnMobile} from '@salesforce/retail-react-app/app/components/responsive'
2924
import {getPathWithLocale} from '@salesforce/retail-react-app/app/utils/url'
3025
import LocaleText from '@salesforce/retail-react-app/app/components/locale-text'
3126
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
3227
import styled from '@emotion/styled'
3328
import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constants'
29+
import {CONSENT_TAGS} from '@salesforce/retail-react-app/app/constants/marketing-consent'
3430
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
3531

36-
const [StylesProvider, useStyles] = createStylesContext('Footer')
32+
const [StylesProvider] = createStylesContext('Footer')
3733
const Footer = ({...otherProps}) => {
3834
const styles = useMultiStyleConfig('Footer')
3935
const intl = useIntl()
@@ -129,13 +125,13 @@ const Footer = ({...otherProps}) => {
129125
links={makeOurCompanyLinks()}
130126
/>
131127
<Box>
132-
<Subscribe />
128+
<SubscribeMarketingConsent tag={CONSENT_TAGS.EMAIL_CAPTURE} />
133129
</Box>
134130
</SimpleGrid>
135131
</HideOnMobile>
136132

137133
<HideOnDesktop>
138-
<Subscribe />
134+
<SubscribeMarketingConsent tag={CONSENT_TAGS.EMAIL_CAPTURE} />
139135
</HideOnDesktop>
140136

141137
{showLocaleSelector && (
@@ -202,56 +198,6 @@ const Footer = ({...otherProps}) => {
202198

203199
export default Footer
204200

205-
const Subscribe = ({...otherProps}) => {
206-
const styles = useStyles()
207-
const intl = useIntl()
208-
return (
209-
<Box {...styles.subscribe} {...otherProps}>
210-
<Heading as="h2" {...styles.subscribeHeading}>
211-
{intl.formatMessage({
212-
id: 'footer.subscribe.heading.first_to_know',
213-
defaultMessage: 'Be the first to know'
214-
})}
215-
</Heading>
216-
<Text {...styles.subscribeMessage}>
217-
{intl.formatMessage({
218-
id: 'footer.subscribe.description.sign_up',
219-
defaultMessage: 'Sign up to stay in the loop about the hottest deals'
220-
})}
221-
</Text>
222-
223-
<Box>
224-
<InputGroup>
225-
{/* Had to swap the following InputRightElement and Input
226-
to avoid the hydration error due to mismatched html between server and client side.
227-
This is a workaround for Lastpass plugin that automatically injects its icon for input fields.
228-
*/}
229-
<InputRightElement {...styles.subscribeButtonContainer}>
230-
<Button variant="footer">
231-
{intl.formatMessage({
232-
id: 'footer.subscribe.button.sign_up',
233-
defaultMessage: 'Sign Up'
234-
})}
235-
</Button>
236-
</InputRightElement>
237-
<Input
238-
type="email"
239-
placeholder="you@email.com"
240-
aria-label={intl.formatMessage({
241-
id: 'footer.subscribe.email.assistive_msg',
242-
defaultMessage: 'Email address for newsletter'
243-
})}
244-
id="subscribe-email"
245-
{...styles.subscribeField}
246-
/>
247-
</InputGroup>
248-
</Box>
249-
250-
<SocialIcons variant="flex-start" pinterestInnerColor="black" {...styles.socialIcons} />
251-
</Box>
252-
)
253-
}
254-
255201
const LegalLinks = ({variant}) => {
256202
const intl = useIntl()
257203
return (

packages/template-retail-react-app/app/components/footer/index.test.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,23 @@ import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-u
1212

1313
test('renders component', () => {
1414
renderWithProviders(<Footer />)
15-
expect(screen.getByRole('link', {name: 'Privacy Policy'})).toBeInTheDocument()
15+
const privacyLinks = screen.getAllByRole('link', {name: 'Privacy Policy'})
16+
expect(privacyLinks.length).toBeGreaterThanOrEqual(1)
1617
})
1718

1819
test('renders mobile version by default', () => {
1920
renderWithProviders(<Footer />)
2021
// This link is hidden initially, but would be shown for desktop
2122
expect(screen.getByRole('link', {name: 'About Us', hidden: true})).toBeInTheDocument()
2223
})
24+
25+
test('renders SubscribeForm within Footer', () => {
26+
renderWithProviders(<Footer />)
27+
// Verify SubscribeForm renders correctly inside Footer
28+
// Note: Footer renders SubscribeForm twice (mobile + desktop versions), so we use getAllBy
29+
expect(screen.getByRole('heading', {name: /subscribe to stay updated/i})).toBeInTheDocument()
30+
const emailInputs = screen.getAllByLabelText(/email address for newsletter/i)
31+
expect(emailInputs.length).toBeGreaterThanOrEqual(1)
32+
const signUpButtons = screen.getAllByRole('button', {name: /subscribe/i})
33+
expect(signUpButtons.length).toBeGreaterThanOrEqual(1)
34+
})
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import {useCallback, useMemo, useState} from 'react'
9+
import {
10+
CONSENT_CHANNELS,
11+
CONSENT_STATUS
12+
} from '@salesforce/retail-react-app/app/constants/marketing-consent'
13+
import {useMarketingConsent} from '@salesforce/retail-react-app/app/hooks/use-marketing-consent'
14+
import {validateEmail} from '@salesforce/retail-react-app/app/utils/subscription-validators'
15+
import {useIntl} from 'react-intl'
16+
17+
/**
18+
* Hook for managing email subscription form state and submission.
19+
* This hook dynamically fetches all subscriptions matching a given tag and email channel,
20+
* then opts the user into ALL matching subscriptions when they submit their email.
21+
*
22+
* Subscriptions are fetched on-demand when the user clicks submit, not on component mount.
23+
*
24+
* This allows marketers to configure subscriptions without code changes to the storefront UI.
25+
*
26+
* @param {Object} options
27+
* @param {string|Array<string>} options.tag - The consent tag(s) to filter subscriptions by (e.g., CONSENT_TAGS.EMAIL_CAPTURE or [CONSENT_TAGS.EMAIL_CAPTURE, CONSENT_TAGS.ACCOUNT])
28+
* @returns {Object} Email subscription state and actions
29+
* @returns {Object} return.state - Current form state
30+
* @returns {string} return.state.email - Current email value
31+
* @returns {boolean} return.state.isLoading - Whether submission is in progress
32+
* @returns {Object} return.state.feedback - Feedback message and type
33+
* @returns {string} return.state.feedback.message - User-facing message
34+
* @returns {string} return.state.feedback.type - Message type ('success' | 'error')
35+
* @returns {Object} return.actions - Available actions
36+
* @returns {Function} return.actions.setEmail - Update email value
37+
* @returns {Function} return.actions.submit - Submit the subscription
38+
*
39+
* @example
40+
* const {state, actions} = useEmailSubscription({
41+
* tag: CONSENT_TAGS.EMAIL_CAPTURE
42+
* })
43+
*/
44+
export const useEmailSubscription = ({tag} = {}) => {
45+
// Normalize tag to array for API call
46+
const tags = useMemo(() => {
47+
if (!tag) return []
48+
return Array.isArray(tag) ? tag : [tag]
49+
}, [tag])
50+
51+
const {
52+
refetch: fetchSubscriptions,
53+
updateSubscriptions,
54+
isUpdating
55+
} = useMarketingConsent({
56+
tags,
57+
enabled: false
58+
})
59+
60+
const intl = useIntl()
61+
const {formatMessage} = intl
62+
63+
const [email, setEmail] = useState('')
64+
const [message, setMessage] = useState(null)
65+
const [messageType, setMessageType] = useState('success')
66+
67+
const messages = useMemo(
68+
() => ({
69+
success_confirmation: formatMessage({
70+
id: 'footer.success_confirmation',
71+
defaultMessage: 'Thanks for subscribing!'
72+
}),
73+
error: {
74+
enter_valid_email: formatMessage({
75+
id: 'footer.error.enter_valid_email',
76+
defaultMessage: 'Enter a valid email address.'
77+
}),
78+
generic_error: formatMessage({
79+
id: 'footer.error.generic_error',
80+
defaultMessage: "We couldn't process the subscription. Try again."
81+
})
82+
}
83+
}),
84+
[formatMessage]
85+
)
86+
87+
const handleSignUp = useCallback(async () => {
88+
// Validate email using the utility validator
89+
const validation = validateEmail(email)
90+
91+
if (!validation.valid) {
92+
setMessage(messages.error.enter_valid_email)
93+
setMessageType('error')
94+
return
95+
}
96+
97+
try {
98+
setMessage(null)
99+
100+
// Fetch subscriptions on-demand when submitting
101+
const {data: freshSubscriptionsData} = await fetchSubscriptions()
102+
const allSubscriptions = freshSubscriptionsData?.data || []
103+
104+
// Find matching subscriptions
105+
const matchingSubs = allSubscriptions.filter((sub) => {
106+
const hasEmailChannel = sub.channels?.includes(CONSENT_CHANNELS.EMAIL)
107+
const hasAnyTag = tags.some((t) => sub.tags?.includes(t))
108+
return hasEmailChannel && hasAnyTag
109+
})
110+
111+
// Check if there are any matching subscriptions
112+
if (matchingSubs.length === 0) {
113+
const tagList = tags.join(', ')
114+
console.error(
115+
`[useEmailSubscription] No subscriptions found for tag(s) "${tagList}" and channel "${CONSENT_CHANNELS.EMAIL}".`
116+
)
117+
setMessage(messages.error.generic_error)
118+
setMessageType('error')
119+
return
120+
}
121+
122+
// Build array of subscription updates for ALL matching subscriptions
123+
const subscriptionUpdates = matchingSubs.map((sub) => ({
124+
subscriptionId: sub.subscriptionId,
125+
contactPointValue: email,
126+
channel: CONSENT_CHANNELS.EMAIL,
127+
status: CONSENT_STATUS.OPT_IN
128+
}))
129+
130+
console.log(
131+
`[useEmailSubscription] Opting in to ${subscriptionUpdates.length} subscription(s):`,
132+
subscriptionUpdates.map((s) => s.subscriptionId)
133+
)
134+
135+
// Submit the consent using bulk API (ShopperConsents API v1.1.3)
136+
await updateSubscriptions(subscriptionUpdates)
137+
138+
setMessage(messages.success_confirmation)
139+
setMessageType('success')
140+
setEmail('')
141+
} catch (err) {
142+
console.error('[useEmailSubscription] Subscription error:', err)
143+
setMessage(messages.error.generic_error)
144+
setMessageType('error')
145+
}
146+
}, [email, tags, fetchSubscriptions, updateSubscriptions, messages])
147+
148+
return {
149+
state: {
150+
email,
151+
isLoading: isUpdating,
152+
feedback: {message, type: messageType}
153+
},
154+
actions: {
155+
setEmail,
156+
submit: handleSignUp
157+
}
158+
}
159+
}
160+
161+
export default useEmailSubscription

0 commit comments

Comments
 (0)