Skip to content

Commit 1b9b1fd

Browse files
syadupathi-sfkumaravinashcommercecloud
authored andcommitted
@W-19425801 Guest Shopper Flow (#3417)
* initial changes * tests and other adjustments * fix to eliminate the OTP modal fluctuating its visibility * clean up code * skip changelog * cleanup the hook * translations * guest flow changes * merge basket change (#3448) * avoid duplicate OTP * tweak for returning vs newly regstered guest users * tests and lint * minor changes * address code review comment * fix lint in commerce-sdk-react * refactor minor --------- Co-authored-by: kumaravinashcommercecloud <[email protected]>
1 parent 53259b7 commit 1b9b1fd

19 files changed

+1191
-361
lines changed

packages/commerce-sdk-react/src/auth/index.ts

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import {
2121
isOriginTrusted,
2222
onClient,
2323
getDefaultCookieAttributes,
24-
isAbsoluteUrl,
2524
stringToBase64,
2625
extractCustomParameters
2726
} from '../utils'
@@ -96,10 +95,19 @@ type AuthorizePasswordlessParams = {
9695
callbackURI?: string
9796
userid: string
9897
mode?: string
98+
/** When true, SLAS will register the customer as part of the passwordless flow */
99+
register_customer?: boolean | string
100+
/** Optional registration details forwarded to SLAS when register_customer=true */
101+
first_name?: string
102+
last_name?: string
103+
email?: string
104+
phone_number?: string
99105
}
100106

101107
type GetPasswordLessAccessTokenParams = {
102108
pwdlessLoginToken: string
109+
/** When true, SLAS will register the customer if not already registered */
110+
register_customer?: boolean | string
103111
}
104112

105113
/**
@@ -1260,26 +1268,54 @@ class Auth {
12601268
* A wrapper method for commerce-sdk-isomorphic helper: authorizePasswordless.
12611269
*/
12621270
async authorizePasswordless(parameters: AuthorizePasswordlessParams) {
1271+
const slasClient = this.client
12631272
const usid = this.get('usid')
12641273
const callbackURI = parameters.callbackURI || this.passwordlessLoginCallbackURI
1265-
const finalMode = callbackURI ? 'callback' : parameters.mode || 'sms'
1274+
const finalMode = parameters.mode || (callbackURI ? 'callback' : 'sms')
12661275

1267-
const res = await helpers.authorizePasswordless({
1268-
slasClient: this.client,
1269-
credentials: {
1270-
clientSecret: this.clientSecret
1276+
const options = {
1277+
headers: {
1278+
Authorization: ''
12711279
},
12721280
parameters: {
1273-
...(callbackURI && {callbackURI: callbackURI}),
1281+
...(parameters.register_customer !== undefined && {
1282+
register_customer:
1283+
typeof parameters.register_customer === 'boolean'
1284+
? String(parameters.register_customer)
1285+
: parameters.register_customer
1286+
})
1287+
},
1288+
body: {
1289+
user_id: parameters.userid,
1290+
mode: finalMode,
1291+
// Include usid and site as required by SLAS
12741292
...(usid && {usid}),
1275-
userid: parameters.userid,
1276-
mode: finalMode
1293+
channel_id: slasClient.clientConfig.parameters.siteId,
1294+
...(callbackURI && {callback_uri: callbackURI}),
1295+
...(parameters.last_name && {last_name: parameters.last_name}),
1296+
...(parameters.email && {email: parameters.email}),
1297+
...(parameters.first_name && {first_name: parameters.first_name}),
1298+
...(parameters.phone_number && {phone_number: parameters.phone_number})
12771299
}
1278-
})
1279-
if (res && res.status !== 200) {
1280-
const errorData = await res.json()
1281-
throw new Error(`${res.status} ${String(errorData.message)}`)
1300+
} as {
1301+
headers?: {[key: string]: string}
1302+
parameters?: Record<string, string>
1303+
body: ShopperLoginTypes.authorizePasswordlessCustomerBodyType &
1304+
helpers.CustomRequestBody
12821305
}
1306+
1307+
// Use Basic auth header when using private client
1308+
if (this.clientSecret) {
1309+
options.headers = options.headers || {}
1310+
options.headers.Authorization = `Basic ${stringToBase64(
1311+
`${slasClient.clientConfig.parameters.clientId}:${this.clientSecret}`
1312+
)}`
1313+
} else {
1314+
// If not using private client, avoid sending Authorization header
1315+
delete options.headers
1316+
}
1317+
1318+
const res = await slasClient.authorizePasswordlessCustomer(options)
12831319
return res
12841320
}
12851321

@@ -1289,14 +1325,22 @@ class Auth {
12891325
async getPasswordLessAccessToken(parameters: GetPasswordLessAccessTokenParams) {
12901326
const pwdlessLoginToken = parameters.pwdlessLoginToken || ''
12911327
const dntPref = this.getDnt({includeDefaults: true})
1328+
const usid = this.get('usid')
12921329
const token = await helpers.getPasswordLessAccessToken({
12931330
slasClient: this.client,
12941331
credentials: {
12951332
clientSecret: this.clientSecret
12961333
},
12971334
parameters: {
12981335
pwdlessLoginToken,
1299-
dnt: dntPref !== undefined ? String(dntPref) : undefined
1336+
dnt: dntPref !== undefined ? String(dntPref) : undefined,
1337+
...(usid && {usid}),
1338+
...(parameters.register_customer !== undefined && {
1339+
register_customer:
1340+
typeof parameters.register_customer === 'boolean'
1341+
? String(parameters.register_customer)
1342+
: parameters.register_customer
1343+
})
13001344
}
13011345
})
13021346
const isGuest = false
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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+
import {useCommerceApi} from '@salesforce/commerce-sdk-react'
8+
import useAuthContext from '@salesforce/commerce-sdk-react/hooks/useAuthContext'
9+
import {useShopperBasketsMutation} from '@salesforce/commerce-sdk-react'
10+
11+
// Dev-only debug logger to keep recovery silent in production
12+
const devDebug = (...args) => {
13+
if (process.env.NODE_ENV !== 'production') {
14+
console.debug(...args)
15+
}
16+
}
17+
18+
/**
19+
* Reusable basket recovery hook to stabilize basket after OTP/auth swap.
20+
* - Attempts merge (if caller already merged, pass skipMerge=true)
21+
* - Hydrates destination basket by id with retry
22+
* - Fallbacks to create/copy items and re-apply shipping
23+
*/
24+
const useBasketRecovery = () => {
25+
const api = useCommerceApi()
26+
const auth = useAuthContext()
27+
28+
const mergeBasket = useShopperBasketsMutation('mergeBasket')
29+
const createBasket = useShopperBasketsMutation('createBasket')
30+
const addItemToBasket = useShopperBasketsMutation('addItemToBasket')
31+
const updateShippingAddressForShipment = useShopperBasketsMutation(
32+
'updateShippingAddressForShipment'
33+
)
34+
const updateShippingMethodForShipment = useShopperBasketsMutation(
35+
'updateShippingMethodForShipment'
36+
)
37+
38+
const copyItemsAndShipping = async (
39+
destinationBasketId,
40+
items = [],
41+
shipment = null,
42+
shipmentId = 'me'
43+
) => {
44+
if (items?.length) {
45+
const payload = items.map((item) => {
46+
const productId = item.productId || item.product_id || item.id || item.product?.id
47+
const quantity = item.quantity || item.amount || 1
48+
const variationAttributes =
49+
item.variationAttributes || item.variation_attributes || []
50+
const optionItems = item.optionItems || item.option_items || []
51+
const mappedVariations = Array.isArray(variationAttributes)
52+
? variationAttributes.map((v) => ({
53+
attributeId: v.attributeId || v.attribute_id || v.id,
54+
valueId: v.valueId || v.value_id || v.value
55+
}))
56+
: []
57+
const mappedOptions = Array.isArray(optionItems)
58+
? optionItems.map((o) => ({
59+
optionId: o.optionId || o.option_id || o.id,
60+
optionValueId:
61+
o.optionValueId || o.optionValue || o.option_value || o.value
62+
}))
63+
: []
64+
const obj = {productId, quantity}
65+
if (mappedVariations.length) obj.variationAttributes = mappedVariations
66+
if (mappedOptions.length) obj.optionItems = mappedOptions
67+
return obj
68+
})
69+
await addItemToBasket.mutateAsync({
70+
parameters: {basketId: destinationBasketId},
71+
body: payload
72+
})
73+
}
74+
75+
if (shipment) {
76+
const shippingAddress = shipment.shippingAddress
77+
if (shippingAddress) {
78+
await updateShippingAddressForShipment.mutateAsync({
79+
parameters: {basketId: destinationBasketId, shipmentId},
80+
body: {
81+
address1: shippingAddress.address1,
82+
address2: shippingAddress.address2,
83+
city: shippingAddress.city,
84+
countryCode: shippingAddress.countryCode,
85+
firstName: shippingAddress.firstName,
86+
lastName: shippingAddress.lastName,
87+
phone: shippingAddress.phone,
88+
postalCode: shippingAddress.postalCode,
89+
stateCode: shippingAddress.stateCode
90+
}
91+
})
92+
}
93+
const methodId = shipment?.shippingMethod?.id
94+
if (methodId) {
95+
await updateShippingMethodForShipment.mutateAsync({
96+
parameters: {basketId: destinationBasketId, shipmentId},
97+
body: {id: methodId}
98+
})
99+
}
100+
}
101+
}
102+
103+
const recoverBasketAfterAuth = async ({
104+
preLoginItems = [],
105+
shipment = null,
106+
doMerge = true
107+
} = {}) => {
108+
// Ensure fresh token in provider
109+
await auth.refreshAccessToken()
110+
111+
let destinationBasketId
112+
if (doMerge) {
113+
try {
114+
const merged = await mergeBasket.mutateAsync({
115+
parameters: {createDestinationBasket: true}
116+
})
117+
destinationBasketId = merged?.basketId || merged?.basket_id || merged?.id
118+
} catch (_e) {
119+
devDebug('useBasketRecovery: mergeBasket failed; proceeding without merge', _e)
120+
}
121+
}
122+
123+
if (!destinationBasketId) {
124+
try {
125+
const list = await api.shopperCustomers.getCustomerBaskets({
126+
parameters: {customerId: 'me'}
127+
})
128+
destinationBasketId = list?.baskets?.[0]?.basketId
129+
} catch (_e) {
130+
devDebug(
131+
'useBasketRecovery: getCustomerBaskets failed; will attempt hydration/create',
132+
_e
133+
)
134+
}
135+
}
136+
137+
if (destinationBasketId) {
138+
// Avoid triggering a hook-level refetch that can cause UI remounts.
139+
// Instead, probe the destination basket directly for shipment id.
140+
let hydrated = null
141+
try {
142+
hydrated = await api.shopperBaskets.getBasket({
143+
headers: {authorization: `Bearer ${auth.get('access_token')}`},
144+
parameters: {basketId: destinationBasketId}
145+
})
146+
} catch (_e) {
147+
devDebug('useBasketRecovery: getBasket hydration failed', _e)
148+
hydrated = null
149+
}
150+
if (!hydrated) {
151+
try {
152+
const created = await createBasket.mutateAsync({})
153+
destinationBasketId =
154+
created?.basketId ||
155+
created?.basket_id ||
156+
created?.id ||
157+
destinationBasketId
158+
await copyItemsAndShipping(destinationBasketId, preLoginItems, shipment)
159+
} catch (_e) {
160+
devDebug(
161+
'useBasketRecovery: createBasket/copyItems failed during hydration path',
162+
_e
163+
)
164+
}
165+
} else if (shipment) {
166+
// PII (shipping address/method) is not merged by API; re-apply from snapshot
167+
try {
168+
const effectiveDestId = hydrated?.basketId || destinationBasketId
169+
const destShipmentId =
170+
hydrated?.shipments?.[0]?.shipmentId || hydrated?.shipments?.[0]?.id || 'me'
171+
await copyItemsAndShipping(effectiveDestId, [], shipment, destShipmentId)
172+
} catch (_e) {
173+
devDebug('useBasketRecovery: re-applying shipping from snapshot failed', _e)
174+
}
175+
}
176+
} else {
177+
try {
178+
const created = await createBasket.mutateAsync({})
179+
destinationBasketId = created?.basketId || created?.basket_id || created?.id
180+
await copyItemsAndShipping(destinationBasketId, preLoginItems, shipment)
181+
} catch (_e) {
182+
devDebug('useBasketRecovery: createBasket/copyItems failed in fallback path', _e)
183+
}
184+
}
185+
186+
return destinationBasketId
187+
}
188+
189+
return {recoverBasketAfterAuth}
190+
}
191+
192+
export default useBasketRecovery

0 commit comments

Comments
 (0)