Skip to content

Commit f005c92

Browse files
committed
pull in OTP Auth modal changes from 1 click checkout feature branch
1 parent bd6d1d0 commit f005c92

File tree

4 files changed

+1384
-0
lines changed

4 files changed

+1384
-0
lines changed
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
/*
2+
* Copyright (c) 2024, salesforce.com, 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 React, {useState, useEffect} from 'react'
9+
import PropTypes from 'prop-types'
10+
import {FormattedMessage} from 'react-intl'
11+
import {
12+
Button,
13+
Input,
14+
SimpleGrid,
15+
Stack,
16+
Text,
17+
HStack,
18+
Modal,
19+
ModalBody,
20+
ModalCloseButton,
21+
ModalContent,
22+
ModalHeader,
23+
ModalOverlay
24+
} from '@salesforce/retail-react-app/app/components/shared/ui'
25+
import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein'
26+
import {useUsid, useCustomerType, useDNT} from '@salesforce/commerce-sdk-react'
27+
import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
28+
import {useOtpInputs} from '@salesforce/retail-react-app/app/hooks/use-otp-inputs'
29+
import {useCountdown} from '@salesforce/retail-react-app/app/hooks/use-countdown'
30+
31+
const OtpAuth = ({
32+
isOpen,
33+
onClose,
34+
form,
35+
handleSendEmailOtp,
36+
handleOtpVerification,
37+
onCheckoutAsGuest,
38+
isGuestRegistration = false,
39+
hideCheckoutAsGuestButton = false
40+
}) => {
41+
const OTP_LENGTH = 8
42+
const [isVerifying, setIsVerifying] = useState(false)
43+
const [error, setError] = useState('')
44+
const [resendTimer, setResendTimer] = useCountdown(0)
45+
46+
// Privacy-aware user identification hooks
47+
const {getUsidWhenReady} = useUsid()
48+
const {isRegistered} = useCustomerType()
49+
const {data: customer} = useCurrentCustomer()
50+
const {effectiveDnt} = useDNT()
51+
52+
// Einstein tracking
53+
const {sendViewPage} = useEinstein()
54+
55+
// Get privacy-compliant user identifier
56+
const getUserIdentifier = async () => {
57+
// Respect Do Not Track
58+
if (effectiveDnt) {
59+
return '__DNT__'
60+
}
61+
// Use customer ID for registered users
62+
if (isRegistered && customer?.customerId) {
63+
return customer.customerId
64+
}
65+
// Use USID for guest users
66+
const usid = await getUsidWhenReady()
67+
return usid
68+
}
69+
70+
const track = async (path, payload = {}) => {
71+
const userId = await getUserIdentifier()
72+
sendViewPage(path, {
73+
userId,
74+
userType: isRegistered ? 'registered' : 'guest',
75+
dntCompliant: effectiveDnt,
76+
...payload
77+
})
78+
}
79+
80+
const otpInputs = useOtpInputs(OTP_LENGTH, (code) => {
81+
if (code.length === OTP_LENGTH) {
82+
handleVerify(code)
83+
}
84+
})
85+
86+
useEffect(() => {
87+
if (isOpen) {
88+
otpInputs.clear()
89+
setError('')
90+
form.setValue('otp', '')
91+
92+
// Track OTP modal view activity
93+
track('/otp-authentication', {
94+
activity: 'otp_modal_viewed',
95+
context: 'authentication'
96+
})
97+
98+
setTimeout(() => otpInputs.inputRefs.current[0]?.focus(), 100)
99+
}
100+
}, [isOpen])
101+
102+
const handleVerify = async (code = otpInputs.values.join('')) => {
103+
if (code.length !== OTP_LENGTH) return
104+
105+
setIsVerifying(true)
106+
setError('')
107+
108+
// Track OTP verification attempt
109+
track('/otp-verification', {
110+
activity: 'otp_verification_attempted',
111+
context: 'authentication',
112+
otpLength: code.length
113+
})
114+
115+
try {
116+
const result = await handleOtpVerification(code)
117+
if (result && !result.success) {
118+
setError(result.error)
119+
otpInputs.clear()
120+
121+
// Track failed OTP verification
122+
track('/otp-verification-failed', {
123+
activity: 'otp_verification_failed',
124+
context: 'authentication',
125+
error: result.error
126+
})
127+
}
128+
} finally {
129+
setIsVerifying(false)
130+
// Track successful OTP verification
131+
track('/otp-verification-success', {
132+
activity: 'otp_verification_successful',
133+
context: 'authentication'
134+
})
135+
}
136+
}
137+
138+
const handleResend = async () => {
139+
setResendTimer(5)
140+
try {
141+
await track('/otp-resend', {
142+
activity: 'otp_code_resent',
143+
context: 'authentication',
144+
resendAttempt: true
145+
})
146+
await handleSendEmailOtp(form.getValues('email'))
147+
} catch (error) {
148+
setResendTimer(0)
149+
await track('/otp-resend-failed', {
150+
activity: 'otp_resend_failed',
151+
context: 'authentication',
152+
error: error.message
153+
})
154+
console.error('Error resending code:', error)
155+
}
156+
}
157+
158+
const handleCheckoutAsGuest = async () => {
159+
// Track checkout as guest selection
160+
await track('/checkout-as-guest', {
161+
activity: 'checkout_as_guest_selected',
162+
context: 'otp_authentication',
163+
userChoice: 'guest_checkout'
164+
})
165+
166+
if (onCheckoutAsGuest) {
167+
onCheckoutAsGuest()
168+
}
169+
onClose()
170+
}
171+
172+
const handleInputChange = (index, value) => {
173+
const code = otpInputs.setValue(index, value)
174+
setError('') // Clear error on user input
175+
if (typeof code === 'string') {
176+
form.setValue('otp', code)
177+
if (code.length === OTP_LENGTH) {
178+
handleVerify(code)
179+
}
180+
}
181+
}
182+
183+
const isResendDisabled = resendTimer > 0 || isVerifying
184+
185+
return (
186+
<Modal isOpen={isOpen} onClose={onClose} isCentered size="lg" closeOnOverlayClick={false}>
187+
<ModalOverlay />
188+
<ModalContent>
189+
<ModalHeader>
190+
{isGuestRegistration ? (
191+
<FormattedMessage
192+
defaultMessage="Create an account"
193+
id="otp.title.create_account"
194+
/>
195+
) : (
196+
<FormattedMessage
197+
defaultMessage="Confirm it's you"
198+
id="otp.title.confirm_its_you"
199+
/>
200+
)}
201+
</ModalHeader>
202+
<ModalCloseButton disabled={isVerifying} />
203+
<ModalBody pb={6}>
204+
<Stack spacing={12} paddingLeft={4} paddingRight={4} alignItems="center">
205+
<Text fontSize="md" maxWidth="300px" textAlign="center">
206+
{isGuestRegistration ? (
207+
<FormattedMessage
208+
defaultMessage="We sent a one-time password (OTP) to your email. To create your account and proceed to checkout, enter the {otpLength}-digit code below."
209+
id="otp.message.enter_code_for_account_guest"
210+
values={{otpLength: OTP_LENGTH}}
211+
/>
212+
) : (
213+
<FormattedMessage
214+
defaultMessage="To log in to your account, enter the code sent to your email."
215+
id="otp.message.enter_code_for_account_returning"
216+
/>
217+
)}
218+
</Text>
219+
220+
{/* OTP Input */}
221+
<SimpleGrid columns={OTP_LENGTH} spacing={3}>
222+
{Array.from({length: OTP_LENGTH}).map((_, index) => (
223+
<Input
224+
key={index}
225+
ref={(el) => (otpInputs.inputRefs.current[index] = el)}
226+
value={otpInputs.values[index]}
227+
onChange={(e) => handleInputChange(index, e.target.value)}
228+
onKeyDown={(e) => otpInputs.handleKeyDown(index, e)}
229+
onPaste={otpInputs.handlePaste}
230+
type="text"
231+
inputMode="numeric"
232+
maxLength={1}
233+
textAlign="center"
234+
fontSize="lg"
235+
fontWeight="bold"
236+
size="lg"
237+
width="48px"
238+
height="56px"
239+
borderRadius="md"
240+
borderColor="gray.300"
241+
borderWidth="2px"
242+
disabled={isVerifying}
243+
_focus={{
244+
borderColor: 'blue.500',
245+
boxShadow: '0 0 0 1px var(--chakra-colors-blue-500)'
246+
}}
247+
_hover={{
248+
borderColor: 'gray.400'
249+
}}
250+
/>
251+
))}
252+
</SimpleGrid>
253+
254+
{/* Loading indicator during verification */}
255+
{isVerifying && (
256+
<Text fontSize="sm" color="blue.500">
257+
<FormattedMessage
258+
defaultMessage="Verifying code..."
259+
id="otp.message.verifying"
260+
/>
261+
</Text>
262+
)}
263+
264+
{/* Error message */}
265+
{error && (
266+
<Text fontSize="sm" color="red.500" textAlign="center">
267+
{error}
268+
</Text>
269+
)}
270+
271+
{/* Buttons */}
272+
<HStack spacing={4} width="100%" justifyContent="center">
273+
{!hideCheckoutAsGuestButton && (
274+
<Button
275+
onClick={handleCheckoutAsGuest}
276+
variant="solid"
277+
size="lg"
278+
minWidth="160px"
279+
isDisabled={isVerifying}
280+
bg="gray.50"
281+
color="gray.800"
282+
fontWeight="bold"
283+
border="none"
284+
_hover={{
285+
bg: 'gray.100'
286+
}}
287+
_active={{
288+
bg: 'gray.200'
289+
}}
290+
>
291+
{isGuestRegistration ? (
292+
<FormattedMessage
293+
defaultMessage="Cancel"
294+
id="otp.button.cancel_guest_registration"
295+
/>
296+
) : (
297+
<FormattedMessage
298+
defaultMessage="Checkout as a Guest"
299+
id="otp.button.checkout_as_guest"
300+
/>
301+
)}
302+
</Button>
303+
)}
304+
305+
<Button
306+
onClick={handleResend}
307+
variant="solid"
308+
size="lg"
309+
colorScheme={isResendDisabled ? 'gray' : 'blue'}
310+
bg={isResendDisabled ? 'gray.300' : 'blue.500'}
311+
minWidth="160px"
312+
isDisabled={isResendDisabled}
313+
_hover={isResendDisabled ? {} : {bg: 'blue.600'}}
314+
_disabled={{bg: 'gray.300', color: 'gray.600'}}
315+
>
316+
{resendTimer > 0 ? (
317+
<FormattedMessage
318+
defaultMessage="Resend code in {timer} seconds..."
319+
id="otp.button.resend_timer"
320+
values={{timer: resendTimer}}
321+
/>
322+
) : (
323+
<FormattedMessage
324+
defaultMessage="Resend Code"
325+
id="otp.button.resend_code"
326+
/>
327+
)}
328+
</Button>
329+
</HStack>
330+
</Stack>
331+
</ModalBody>
332+
</ModalContent>
333+
</Modal>
334+
)
335+
}
336+
337+
OtpAuth.propTypes = {
338+
isOpen: PropTypes.bool.isRequired,
339+
onClose: PropTypes.func.isRequired,
340+
form: PropTypes.object.isRequired,
341+
handleSendEmailOtp: PropTypes.func.isRequired,
342+
handleOtpVerification: PropTypes.func.isRequired,
343+
onCheckoutAsGuest: PropTypes.func,
344+
isGuestRegistration: PropTypes.bool,
345+
hideCheckoutAsGuestButton: PropTypes.bool
346+
}
347+
348+
export default OtpAuth

0 commit comments

Comments
 (0)