Skip to content

Commit c0e366d

Browse files
jbisaSFsf-avinash-kasipathy
authored andcommitted
@W-18795492 | PWA Kit - Adyen ApplePay in Cart (#2571)
* @W-18795492 | PWA Kit - Adyen ApplePay in Cart * Add test class * Fix tests * Allow for payment methods in an iframe * Remove internal test env urls * Address code review comments * Add more tests
1 parent c113f57 commit c0e366d

File tree

27 files changed

+2908
-5
lines changed

27 files changed

+2908
-5
lines changed

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ const App = (props) => {
188188

189189
// Used to conditionally render header/footer for checkout page
190190
const isCheckout = /\/checkout$/.test(location?.pathname)
191+
const isExpress = /\/express$/.test(location?.pathname)
191192

192193
const {l10n} = site
193194
// Get the current currency to be used through out the app
@@ -288,7 +289,11 @@ const App = (props) => {
288289
trackPage()
289290
}, [location])
290291

291-
return (
292+
return isExpress ? (
293+
<OfflineBoundary isOnline={false}>
294+
<div style={{width: '100%', height: '32px', overflowY: 'hidden'}}>{children}</div>
295+
</OfflineBoundary>
296+
) : (
292297
<Box className="sf-app" {...styles.container}>
293298
<StorefrontPreview getToken={getTokenWhenReady}>
294299
<IntlProvider
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
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 React, {useEffect, useRef} from 'react'
8+
import AdyenCheckout from '@adyen/adyen-web'
9+
import '@adyen/adyen-web/dist/adyen.css'
10+
import PropTypes from 'prop-types'
11+
import {useAdyenExpressCheckout} from '@adyen/adyen-salesforce-pwa'
12+
import {getCurrencyValueForApi} from '@salesforce/retail-react-app/app/components/applePayExpress/utils/parsers'
13+
import {AdyenShippingMethodsService} from '@salesforce/retail-react-app/app/components/applePayExpress/utils/shipping-methods'
14+
import {AdyenShippingAddressService} from '@salesforce/retail-react-app/app/components/applePayExpress/utils/shipping-address'
15+
import {AdyenPaymentsService} from '@salesforce/retail-react-app/app/components/applePayExpress/utils/payments'
16+
17+
const PAYMENT_METHOD = 'applepay';
18+
const EXPRESS_PAYMENT_AVAILABLE = 'express.payment.available';
19+
const EXPRESS_PAYMENT_UNAVAILABLE = 'express.payment.unavailable';
20+
const EXPRESS_PAYMENT_SUCCESS = 'express.payment.success';
21+
const EXPRESS_PAYMENT_FAILURE = 'express.payment.failure';
22+
const EXPRESS_PAYMENT_CANCEL = 'express.payment.cancel';
23+
24+
const sendExpressMessage = (type, payload = {}) => {
25+
window.parent.postMessage(
26+
{
27+
type,
28+
payload
29+
},
30+
'*'
31+
);
32+
};
33+
34+
export const getApplePaymentMethodConfig = (paymentMethodsResponse) => {
35+
const applePayPaymentMethod = paymentMethodsResponse?.paymentMethods?.find(
36+
(pm) => pm.type === PAYMENT_METHOD
37+
)
38+
return applePayPaymentMethod?.configuration || null
39+
}
40+
41+
export const getCustomerShippingDetails = (shippingContact) => {
42+
return {
43+
deliveryAddress: {
44+
city: shippingContact.locality,
45+
country: shippingContact.countryCode,
46+
houseNumberOrName:
47+
shippingContact.addressLines?.length > 1 ? shippingContact.addressLines[1] : '',
48+
postalCode: shippingContact.postalCode,
49+
stateOrProvince: shippingContact.administrativeArea,
50+
street: shippingContact.addressLines?.[0]
51+
},
52+
profile: {
53+
firstName: shippingContact.givenName,
54+
lastName: shippingContact.familyName,
55+
email: shippingContact.emailAddress,
56+
phone: shippingContact.phoneNumber
57+
}
58+
}
59+
}
60+
61+
export const getCustomerBillingDetails = (billingContact) => {
62+
return {
63+
billingAddress: {
64+
city: billingContact.locality,
65+
country: billingContact.countryCode,
66+
houseNumberOrName:
67+
billingContact?.addressLines?.length > 1 ? billingContact.addressLines[1] : '',
68+
postalCode: billingContact.postalCode,
69+
stateOrProvince: billingContact.administrativeArea,
70+
street: billingContact.addressLines?.[0]
71+
}
72+
}
73+
}
74+
75+
export const getAppleButtonConfig = (
76+
authToken,
77+
site,
78+
basket,
79+
shippingMethods,
80+
applePayConfig,
81+
navigate,
82+
fetchShippingMethods
83+
) => {
84+
let applePayAmount = basket.orderTotal
85+
const buttonConfig = {
86+
showPayButton: true,
87+
isExpress: true,
88+
configuration: applePayConfig,
89+
amount: {
90+
value: getCurrencyValueForApi(basket.orderTotal, basket.currency),
91+
currency: basket.currency
92+
},
93+
requiredShippingContactFields: ['postalAddress', 'name', 'email', 'phone'],
94+
requiredBillingContactFields: ['postalAddress'],
95+
shippingMethods: shippingMethods?.map((sm) => ({
96+
label: sm.name,
97+
detail: sm.description,
98+
identifier: sm.id,
99+
amount: `${sm.price}`
100+
})),
101+
onAuthorized: async (resolve, reject, event) => {
102+
try {
103+
const {shippingContact, billingContact, token} = event.payment
104+
const state = {
105+
data: {
106+
paymentType: 'express',
107+
paymentMethod: {
108+
type: 'applepay',
109+
applePayToken: token.paymentData
110+
},
111+
...getCustomerBillingDetails(billingContact),
112+
...getCustomerShippingDetails(shippingContact)
113+
}
114+
}
115+
const adyenPaymentService = new AdyenPaymentsService(authToken, site)
116+
const paymentsResponse = await adyenPaymentService.submitPayment(
117+
{
118+
...state.data,
119+
origin: state.data.origin ? state.data.origin : window.location.origin
120+
},
121+
basket?.basketId,
122+
basket?.customerInfo?.customerId
123+
)
124+
if (paymentsResponse?.isFinal && paymentsResponse?.isSuccessful) {
125+
const finalPriceUpdate = {
126+
newTotal: {
127+
type: 'final',
128+
label: applePayConfig.merchantName,
129+
amount: `${applePayAmount}`
130+
}
131+
}
132+
resolve(finalPriceUpdate)
133+
134+
var orderId = paymentsResponse?.merchantReference;
135+
136+
sendExpressMessage(EXPRESS_PAYMENT_SUCCESS, {
137+
orderId,
138+
PAYMENT_METHOD
139+
});
140+
} else {
141+
reject()
142+
sendExpressMessage(EXPRESS_PAYMENT_FAILURE, {
143+
PAYMENT_METHOD
144+
});
145+
}
146+
} catch (err) {
147+
reject()
148+
sendExpressMessage(EXPRESS_PAYMENT_FAILURE, {
149+
PAYMENT_METHOD
150+
});
151+
}
152+
},
153+
onSubmit: () => {},
154+
onShippingContactSelected: async (resolve, reject, event) => {
155+
try {
156+
const {shippingContact} = event
157+
const adyenShippingAddressService = new AdyenShippingAddressService(authToken, site)
158+
const adyenShippingMethodsService = new AdyenShippingMethodsService(authToken, site)
159+
const customerShippingDetails = getCustomerShippingDetails(shippingContact)
160+
await adyenShippingAddressService.updateShippingAddress(
161+
basket.basketId,
162+
customerShippingDetails
163+
)
164+
const newShippingMethods = await fetchShippingMethods(
165+
basket?.basketId,
166+
site,
167+
authToken
168+
)
169+
if (!newShippingMethods?.applicableShippingMethods?.length) {
170+
reject()
171+
} else {
172+
const response = await adyenShippingMethodsService.updateShippingMethod(
173+
newShippingMethods.applicableShippingMethods[0].id,
174+
basket.basketId
175+
)
176+
buttonConfig.amount = {
177+
value: getCurrencyValueForApi(response.orderTotal, response.currency),
178+
currency: response.currency
179+
}
180+
applePayAmount = response.orderTotal
181+
const finalPriceUpdate = {
182+
newShippingMethods: newShippingMethods?.applicableShippingMethods?.map(
183+
(sm) => ({
184+
label: sm.name,
185+
detail: sm.description,
186+
identifier: sm.id,
187+
amount: `${sm.price}`
188+
})
189+
),
190+
newTotal: {
191+
type: 'final',
192+
label: applePayConfig.merchantName,
193+
amount: `${applePayAmount}`
194+
}
195+
}
196+
resolve(finalPriceUpdate)
197+
}
198+
} catch (err) {
199+
reject()
200+
}
201+
},
202+
onShippingMethodSelected: async (resolve, reject, event) => {
203+
try {
204+
const {shippingMethod} = event
205+
const adyenShippingMethodsService = new AdyenShippingMethodsService(authToken, site)
206+
const response = await adyenShippingMethodsService.updateShippingMethod(
207+
shippingMethod.identifier,
208+
basket.basketId
209+
)
210+
if (response.error) {
211+
reject()
212+
} else {
213+
buttonConfig.amount = {
214+
value: getCurrencyValueForApi(response.orderTotal, response.currency),
215+
currency: response.currency
216+
}
217+
applePayAmount = response.orderTotal
218+
const applePayShippingMethodUpdate = {
219+
newTotal: {
220+
type: 'final',
221+
label: applePayConfig.merchantName,
222+
amount: `${applePayAmount}`
223+
}
224+
}
225+
resolve(applePayShippingMethodUpdate)
226+
}
227+
} catch (err) {
228+
reject()
229+
}
230+
},
231+
onError: (error, component) => {
232+
if (error.name === 'CANCEL') {
233+
sendExpressMessage(EXPRESS_PAYMENT_CANCEL, {
234+
PAYMENT_METHOD
235+
});
236+
} else {
237+
sendExpressMessage(EXPRESS_PAYMENT_FAILURE, {
238+
PAYMENT_METHOD
239+
});
240+
}
241+
}
242+
}
243+
return buttonConfig
244+
}
245+
246+
export const ApplePayExpress = () => {
247+
const {
248+
adyenEnvironment,
249+
adyenPaymentMethods,
250+
basket,
251+
locale,
252+
site,
253+
authToken,
254+
navigate,
255+
shippingMethods,
256+
fetchShippingMethods
257+
} = useAdyenExpressCheckout()
258+
const paymentContainer = useRef(null)
259+
260+
useEffect(() => {
261+
let isCanceled = false;
262+
263+
const createCheckout = async () => {
264+
if (isCanceled) {
265+
return;
266+
}
267+
268+
const handleApplePayUnavailable = () => {
269+
sendExpressMessage(EXPRESS_PAYMENT_UNAVAILABLE, {
270+
PAYMENT_METHOD
271+
});
272+
};
273+
274+
try {
275+
let checkout;
276+
try {
277+
checkout = await AdyenCheckout({
278+
environment: adyenEnvironment?.ADYEN_ENVIRONMENT,
279+
clientKey: adyenEnvironment?.ADYEN_CLIENT_KEY,
280+
locale: locale.id,
281+
analytics: {
282+
analyticsData: {
283+
applicationInfo: adyenPaymentMethods?.applicationInfo
284+
}
285+
}
286+
});
287+
} catch (ex) {
288+
handleApplePayUnavailable();
289+
return;
290+
}
291+
292+
const applePaymentMethodConfig = getApplePaymentMethodConfig(adyenPaymentMethods)
293+
const appleButtonConfig = getAppleButtonConfig(
294+
authToken,
295+
site,
296+
basket,
297+
shippingMethods?.applicableShippingMethods,
298+
applePaymentMethodConfig,
299+
navigate,
300+
fetchShippingMethods
301+
)
302+
303+
let applePayButton;
304+
try {
305+
applePayButton = await checkout.create('applepay', appleButtonConfig);
306+
} catch (ex) {
307+
handleApplePayUnavailable();
308+
return;
309+
}
310+
311+
let isApplePayButtonAvailable = false;
312+
try {
313+
isApplePayButtonAvailable = await applePayButton.isAvailable();
314+
} catch (ex) {
315+
isApplePayButtonAvailable = false;
316+
}
317+
318+
if (!isApplePayButtonAvailable) {
319+
handleApplePayUnavailable();
320+
return;
321+
}
322+
323+
try {
324+
await applePayButton.mount(paymentContainer.current);
325+
sendExpressMessage(EXPRESS_PAYMENT_AVAILABLE, {
326+
PAYMENT_METHOD
327+
});
328+
} catch (error) {
329+
handleApplePayUnavailable();
330+
}
331+
} catch (err) {
332+
console.error('Full error details:', err);
333+
const isMissingOrderTotalError = err instanceof TypeError && err.message == "undefined is not an object (evaluating 'a.orderTotal')";
334+
if (!isMissingOrderTotalError) {
335+
handleApplePayUnavailable();
336+
}
337+
}
338+
}
339+
340+
createCheckout()
341+
342+
return () => {
343+
isCanceled = true;
344+
};
345+
}, [adyenEnvironment, adyenPaymentMethods])
346+
347+
return (
348+
<>
349+
<div ref={paymentContainer}></div>
350+
</>
351+
)
352+
}
353+
354+
ApplePayExpress.propTypes = {
355+
shippingMethods: PropTypes.array
356+
}

0 commit comments

Comments
 (0)