Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -129,13 +125,13 @@ const Footer = ({...otherProps}) => {
links={makeOurCompanyLinks()}
/>
<Box>
<Subscribe />
<SubscribeMarketingConsent tag={CONSENT_TAGS.EMAIL_CAPTURE} />
</Box>
</SimpleGrid>
</HideOnMobile>

<HideOnDesktop>
<Subscribe />
<SubscribeMarketingConsent tag={CONSENT_TAGS.EMAIL_CAPTURE} />
</HideOnDesktop>

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

export default Footer

const Subscribe = ({...otherProps}) => {
const styles = useStyles()
const intl = useIntl()
return (
<Box {...styles.subscribe} {...otherProps}>
<Heading as="h2" {...styles.subscribeHeading}>
{intl.formatMessage({
id: 'footer.subscribe.heading.first_to_know',
defaultMessage: 'Be the first to know'
})}
</Heading>
<Text {...styles.subscribeMessage}>
{intl.formatMessage({
id: 'footer.subscribe.description.sign_up',
defaultMessage: 'Sign up to stay in the loop about the hottest deals'
})}
</Text>

<Box>
<InputGroup>
{/* 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.
*/}
<InputRightElement {...styles.subscribeButtonContainer}>
<Button variant="footer">
{intl.formatMessage({
id: 'footer.subscribe.button.sign_up',
defaultMessage: 'Sign Up'
})}
</Button>
</InputRightElement>
<Input
type="email"
placeholder="you@email.com"
aria-label={intl.formatMessage({
id: 'footer.subscribe.email.assistive_msg',
defaultMessage: 'Email address for newsletter'
})}
id="subscribe-email"
{...styles.subscribeField}
/>
</InputGroup>
</Box>

<SocialIcons variant="flex-start" pinterestInnerColor="black" {...styles.socialIcons} />
</Box>
)
}

const LegalLinks = ({variant}) => {
const intl = useIntl()
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,23 @@ import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-u

test('renders component', () => {
renderWithProviders(<Footer />)
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', () => {
renderWithProviders(<Footer />)
// 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(<Footer />)
// 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)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* 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 in Business Manager without code changes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kzheng-sfdc is it ok to reference Business Manager here? I see "Business Manager" referenced in a README file for pwa-kit. I could also be more vague here instead.

*
* @param {Object} options
* @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])
* @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 "email". ` +
`Please configure subscriptions in Business Manager with one of these tags: ${tagList}.`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error seems to be more suitable for server side logging than for browser/shopper.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, it shouldn't say anything about Business Manager!! Let me fix that.

)
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
Loading
Loading