Skip to content

Commit ef3320a

Browse files
committed
feat(payment): PAYPAL-0 POC
1 parent 45bbd92 commit ef3320a

File tree

9 files changed

+401
-3
lines changed

9 files changed

+401
-3
lines changed

core/app/[locale]/(default)/cart/page-data.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client';
2+
13
import { getSessionCustomerAccessToken } from '~/auth';
24
import { client } from '~/client';
35
import { FragmentOf, graphql, VariablesOf } from '~/client/graphql';
46
import { getShippingZones } from '~/client/management/get-shipping-zones';
57
import { TAGS } from '~/client/tags';
8+
import { getPreferredCurrencyCode } from '~/lib/currency';
69

710
export const PhysicalItemFragment = graphql(`
811
fragment PhysicalItemFragment on CartPhysicalItem {
@@ -262,6 +265,128 @@ export const getCart = async (variables: Variables) => {
262265
return data;
263266
};
264267

268+
const PaymentWalletsQuery = graphql(`
269+
query PaymentWalletsQuery($filters: PaymentWalletsFilterInput) {
270+
site {
271+
paymentWallets(filter: $filters) {
272+
edges {
273+
node {
274+
entityId
275+
}
276+
}
277+
}
278+
}
279+
}
280+
`);
281+
282+
type PaymentWalletsVariables = VariablesOf<typeof PaymentWalletsQuery>;
283+
284+
export const getPaymentWallets = async (variables: PaymentWalletsVariables) => {
285+
const customerAccessToken = await getSessionCustomerAccessToken();
286+
287+
const { data } = await client.fetch({
288+
document: PaymentWalletsQuery,
289+
customerAccessToken,
290+
fetchOptions: { cache: 'no-store' },
291+
variables,
292+
});
293+
294+
return removeEdgesAndNodes(data.site.paymentWallets).map(({ entityId }) => entityId);
295+
};
296+
297+
const PaymentWalletWithInitializationDataQuery = graphql(`
298+
query PaymentWalletWithInitializationDataQuery($entityId: String!, $cartId: String!) {
299+
site {
300+
paymentWalletWithInitializationData(
301+
filter: { paymentWalletEntityId: $entityId, cartEntityId: $cartId }
302+
) {
303+
clientToken
304+
initializationData
305+
}
306+
}
307+
}
308+
`);
309+
310+
export const getPaymentWalletWithInitializationData = async (entityId: string, cartId: string) => {
311+
const { data } = await client.fetch({
312+
document: PaymentWalletWithInitializationDataQuery,
313+
variables: {
314+
entityId,
315+
cartId,
316+
},
317+
customerAccessToken: await getSessionCustomerAccessToken(),
318+
fetchOptions: { cache: 'no-store' },
319+
});
320+
321+
return data.site.paymentWalletWithInitializationData;
322+
};
323+
324+
const CurrencyQuery = graphql(`
325+
query Currency($currencyCode: currencyCode!) {
326+
site {
327+
currency(currencyCode: $currencyCode) {
328+
display {
329+
decimalPlaces
330+
symbol
331+
}
332+
name
333+
code
334+
}
335+
}
336+
}
337+
`);
338+
339+
export const getCurrencyData = async (currencyCode?: string) => {
340+
const code = await getPreferredCurrencyCode(currencyCode);
341+
342+
if (!code) {
343+
throw new Error('Could not get currency code');
344+
}
345+
346+
const customerAccessToken = await getSessionCustomerAccessToken();
347+
348+
const { data } = await client.fetch({
349+
document: CurrencyQuery,
350+
fetchOptions: { cache: 'no-store' },
351+
variables: {
352+
currencyCode: code,
353+
},
354+
customerAccessToken,
355+
});
356+
357+
return data.site.currency;
358+
};
359+
360+
export const createWalletButtonsInitOptions = async (
361+
walletButtons: string[],
362+
cart: {
363+
entityId: string;
364+
currencyCode: string;
365+
},
366+
) => {
367+
const currencyData = await getCurrencyData(cart.currencyCode);
368+
369+
return Promise.all(
370+
walletButtons.map(async (entityId) => {
371+
const initData = await getPaymentWalletWithInitializationData(entityId, cart.entityId);
372+
const methodId = entityId.split('.').join('');
373+
374+
return {
375+
methodId,
376+
containerId: `${methodId}-button`,
377+
[methodId]: {
378+
cartId: cart.entityId,
379+
currency: {
380+
code: currencyData?.code,
381+
decimalPlaces: currencyData?.display.decimalPlaces,
382+
},
383+
...initData,
384+
},
385+
};
386+
}),
387+
);
388+
};
389+
265390
export const getShippingCountries = async (geography: FragmentOf<typeof GeographyFragment>) => {
266391
const hasAccessToken = Boolean(process.env.BIGCOMMERCE_ACCESS_TOKEN);
267392
const shippingZones = hasAccessToken ? await getShippingZones() : [];

core/app/[locale]/(default)/cart/page.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import { updateCouponCode } from './_actions/update-coupon-code';
1010
import { updateLineItem } from './_actions/update-line-item';
1111
import { updateShippingInfo } from './_actions/update-shipping-info';
1212
import { CartViewed } from './_components/cart-viewed';
13-
import { getCart, getShippingCountries } from './page-data';
13+
import {
14+
createWalletButtonsInitOptions,
15+
getCart,
16+
getPaymentWallets,
17+
getShippingCountries,
18+
} from './page-data';
1419

1520
interface Props {
1621
params: Promise<{ locale: string }>;
@@ -61,6 +66,14 @@ export default async function Cart({ params }: Props) {
6166
);
6267
}
6368

69+
const walletButtons = await getPaymentWallets({
70+
filters: {
71+
cartEntityId: cartId,
72+
},
73+
});
74+
75+
const walletButtonsInitOptions = await createWalletButtonsInitOptions(walletButtons, cart);
76+
6477
const lineItems = [...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems];
6578

6679
const formattedLineItems = lineItems.map((item) => ({
@@ -249,6 +262,7 @@ export default async function Cart({ params }: Props) {
249262
}}
250263
summaryTitle={t('CheckoutSummary.title')}
251264
title={t('title')}
265+
walletButtonsInitOptions={walletButtonsInitOptions}
252266
/>
253267
<CartViewed
254268
currencyCode={cart.currencyCode}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use client';
2+
3+
import { useEffect, useRef, useState } from 'react';
4+
5+
import { WalletButtonsInitializer } from '~/lib/wallet-buttons';
6+
import { InitializeButtonProps } from '~/lib/wallet-buttons/types';
7+
8+
export const ClientWalletButtons = ({
9+
walletButtonsInitOptions,
10+
cartId,
11+
}: {
12+
walletButtonsInitOptions: InitializeButtonProps[];
13+
cartId: string;
14+
}) => {
15+
const isMountedRef = useRef(false);
16+
const [buttons, setButtons] = useState<InitializeButtonProps[]>([]);
17+
18+
useEffect(() => {
19+
if (!isMountedRef.current) {
20+
isMountedRef.current = true;
21+
22+
const initWalletButtons = async () => {
23+
const initializedButtons = await new WalletButtonsInitializer().initialize(
24+
walletButtonsInitOptions,
25+
);
26+
27+
setButtons(initializedButtons);
28+
};
29+
30+
void initWalletButtons();
31+
}
32+
}, [cartId, walletButtonsInitOptions]);
33+
34+
return (
35+
<div style={{ display: 'flex', alignItems: 'end', flexDirection: 'column' }}>
36+
{buttons.map((button) =>
37+
button.containerId ? <div id={button.containerId} key={button.containerId} /> : null,
38+
)}
39+
</div>
40+
);
41+
};

core/lib/currency.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import { cookies } from 'next/headers';
55
import type { CurrencyCode } from '~/components/header/fragment';
66
import { CurrencyCodeSchema } from '~/components/header/schema';
77

8-
export async function getPreferredCurrencyCode(): Promise<CurrencyCode | undefined> {
8+
export async function getPreferredCurrencyCode(code?: string): Promise<CurrencyCode | undefined> {
99
const cookieStore = await cookies();
10-
const currencyCode = cookieStore.get('currencyCode')?.value;
10+
const currencyCode = cookieStore.get('currencyCode')?.value || code;
1111

1212
if (!currencyCode) {
1313
return undefined;

core/lib/wallet-buttons/error.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export class InitializationError extends Error {
2+
constructor() {
3+
super(
4+
'Unable to initialize the checkout button because the required script has not been loaded yet.',
5+
);
6+
7+
this.name = 'InitializationError';
8+
}
9+
}

core/lib/wallet-buttons/index.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { InitializationError } from './error';
2+
import { InitializeButtonProps } from './types';
3+
4+
export class WalletButtonsInitializer {
5+
private origin = window.location.origin;
6+
private checkoutSdkUrl = `${this.origin}/v1/loader.js`;
7+
8+
async initialize(
9+
walletButtonsInitOptions: InitializeButtonProps[],
10+
): Promise<InitializeButtonProps[]> {
11+
await this.initializeCheckoutKitLoader();
12+
13+
const checkoutButtonInitializer = await this.initCheckoutButtonInitializer();
14+
15+
return walletButtonsInitOptions.map((buttonOption) => {
16+
checkoutButtonInitializer.initializeHeadlessButton(buttonOption);
17+
18+
return buttonOption;
19+
});
20+
}
21+
22+
private async initializeCheckoutKitLoader(): Promise<void> {
23+
if (window.checkoutKitLoader) {
24+
return;
25+
}
26+
27+
await new Promise((resolve, reject) => {
28+
const script = document.createElement('script');
29+
30+
script.type = 'text/javascript';
31+
script.defer = true;
32+
script.src = this.checkoutSdkUrl;
33+
34+
script.onload = resolve;
35+
script.onerror = reject;
36+
script.onabort = reject;
37+
38+
document.body.append(script);
39+
});
40+
}
41+
42+
private async initCheckoutButtonInitializer() {
43+
if (!window.checkoutKitLoader) {
44+
throw new InitializationError();
45+
}
46+
47+
const checkoutButtonModule = await window.checkoutKitLoader.load('headless-checkout-wallet');
48+
49+
return checkoutButtonModule.createHeadlessCheckoutWalletInitializer();
50+
}
51+
}

core/lib/wallet-buttons/types.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
declare global {
2+
interface Window {
3+
checkoutKitLoader?: CheckoutKitLoader;
4+
}
5+
}
6+
7+
interface CheckoutKitLoader {
8+
load(moduleName: string): Promise<CheckoutKitModule>;
9+
}
10+
11+
interface CheckoutKitModule {
12+
createHeadlessCheckoutWalletInitializer(props?: {
13+
host?: string;
14+
}): CheckoutHeadlessButtonInitializer;
15+
}
16+
17+
interface CheckoutHeadlessButtonInitializer {
18+
initializeHeadlessButton(option: InitializeButtonProps): void;
19+
}
20+
21+
export interface InitializeButtonProps {
22+
[key: string]: unknown;
23+
containerId: string;
24+
methodId: string;
25+
}
26+
27+
export interface WalletInitializationData {
28+
initializationData: null | string;
29+
clientToken: null | string;
30+
}
31+
32+
/**
33+
*
34+
* PayPal Commerce Style options
35+
*
36+
*/
37+
38+
export enum StyleButtonLabel {
39+
paypal = 'paypal',
40+
checkout = 'checkout',
41+
buynow = 'buynow',
42+
pay = 'pay',
43+
installment = 'installment',
44+
}
45+
46+
export enum StyleButtonColor {
47+
gold = 'gold',
48+
blue = 'blue',
49+
silver = 'silver',
50+
black = 'black',
51+
white = 'white',
52+
}
53+
54+
export enum StyleButtonShape {
55+
pill = 'pill',
56+
rect = 'rect',
57+
}
58+
59+
export interface PayPalButtonStyleOptions {
60+
color?: StyleButtonColor;
61+
shape?: StyleButtonShape;
62+
height?: number;
63+
label?: StyleButtonLabel;
64+
}
65+
66+
/**
67+
*
68+
* PayPal Commerce Funding sources
69+
*
70+
*/
71+
export type FundingType = string[];
72+
export type EnableFundingType = FundingType | string;
73+
74+
/**
75+
*
76+
* PayPal Commerce Initialization Data
77+
*
78+
*/
79+
export interface PayPalCommerceInitializationData {
80+
attributionId?: string;
81+
availableAlternativePaymentMethods: FundingType;
82+
buttonStyle?: PayPalButtonStyleOptions;
83+
buyerCountry?: string;
84+
clientId: string;
85+
clientToken?: string;
86+
enabledAlternativePaymentMethods: FundingType;
87+
isDeveloperModeApplicable?: boolean;
88+
intent?: PayPalCommerceIntent;
89+
isAcceleratedCheckoutEnabled?: boolean;
90+
isHostedCheckoutEnabled?: boolean;
91+
isPayPalCreditAvailable?: boolean;
92+
isVenmoEnabled?: boolean;
93+
isGooglePayEnabled?: boolean;
94+
merchantId?: string;
95+
orderId?: string;
96+
shouldRenderFields?: boolean;
97+
shouldRunAcceleratedCheckout?: boolean;
98+
paymentButtonStyles?: Record<string, PayPalButtonStyleOptions>;
99+
}
100+
101+
export enum PayPalCommerceIntent {
102+
AUTHORIZE = 'authorize',
103+
CAPTURE = 'capture',
104+
}

0 commit comments

Comments
 (0)