Skip to content

Commit 5ab4f79

Browse files
committed
feat(payment): PAYPAL-0 POC
1 parent 8c72365 commit 5ab4f79

File tree

9 files changed

+318
-4
lines changed

9 files changed

+318
-4
lines changed

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

Lines changed: 95 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,98 @@ 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+
265360
export const getShippingCountries = async (geography: FragmentOf<typeof GeographyFragment>) => {
266361
const hasAccessToken = Boolean(process.env.BIGCOMMERCE_ACCESS_TOKEN);
267362
const shippingZones = hasAccessToken ? await getShippingZones() : [];

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

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Metadata } from 'next';
22
import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server';
33

4+
import { Streamable } from '@/vibes/soul/lib/streamable';
45
import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/cart';
56
import { getCartId } from '~/lib/cart';
67
import { exists } from '~/lib/utils';
@@ -10,7 +11,13 @@ import { updateCouponCode } from './_actions/update-coupon-code';
1011
import { updateLineItem } from './_actions/update-line-item';
1112
import { updateShippingInfo } from './_actions/update-shipping-info';
1213
import { CartViewed } from './_components/cart-viewed';
13-
import { getCart, getShippingCountries } from './page-data';
14+
import {
15+
getCart,
16+
getCurrencyData,
17+
getPaymentWallets,
18+
getPaymentWalletWithInitializationData,
19+
getShippingCountries,
20+
} from './page-data';
1421

1522
interface Props {
1623
params: Promise<{ locale: string }>;
@@ -26,6 +33,36 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
2633
};
2734
}
2835

36+
const createWalletButtonsInitOptions = async (
37+
walletButtons: string[],
38+
cart: {
39+
entityId: string;
40+
currencyCode: string;
41+
},
42+
) => {
43+
const currencyData = await getCurrencyData(cart.currencyCode);
44+
45+
return Streamable.all(
46+
walletButtons.map(async (entityId) => {
47+
const initData = await getPaymentWalletWithInitializationData(entityId, cart.entityId);
48+
const methodId = entityId.split('.').join('');
49+
50+
return {
51+
methodId,
52+
containerId: `${methodId}-button`,
53+
[methodId]: {
54+
cartId: cart.entityId,
55+
currency: {
56+
code: currencyData?.code,
57+
decimalPlaces: currencyData?.display.decimalPlaces,
58+
},
59+
...initData,
60+
},
61+
};
62+
}),
63+
);
64+
};
65+
2966
// eslint-disable-next-line complexity
3067
export default async function Cart({ params }: Props) {
3168
const { locale } = await params;
@@ -61,6 +98,16 @@ export default async function Cart({ params }: Props) {
6198
);
6299
}
63100

101+
const walletButtons = await getPaymentWallets({
102+
filters: {
103+
cartEntityId: cartId,
104+
},
105+
});
106+
107+
const walletButtonsInitOptions = Streamable.from(() =>
108+
createWalletButtonsInitOptions(walletButtons, cart),
109+
);
110+
64111
const lineItems = [...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems];
65112

66113
const formattedLineItems = lineItems.map((item) => ({
@@ -249,6 +296,7 @@ export default async function Cart({ params }: Props) {
249296
}}
250297
summaryTitle={t('CheckoutSummary.title')}
251298
title={t('title')}
299+
walletButtonsInitOptions={walletButtonsInitOptions}
252300
/>
253301
<CartViewed
254302
currencyCode={cart.currencyCode}

core/app/api/wallets/graphql/route.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,33 @@ const ENABLE_LIST = [
2929
},
3030
},
3131
},
32+
{
33+
name: 'CheckoutRedirectMutation',
34+
type: 'mutation',
35+
allowedFields: {
36+
cart: {
37+
createCartRedirectUrls: true,
38+
},
39+
},
40+
},
41+
{
42+
name: 'AddCheckoutBillingAddressMutation',
43+
type: 'mutation',
44+
allowedFields: {
45+
checkout: {
46+
billingAddress: true,
47+
},
48+
},
49+
},
50+
{
51+
name: 'UpdateCheckoutBillingAddressMutation',
52+
type: 'mutation',
53+
allowedFields: {
54+
checkout: {
55+
billingAddress: true,
56+
},
57+
},
58+
},
3259
];
3360

3461
export const POST = async (request: NextRequest) => {
@@ -66,7 +93,7 @@ export const POST = async (request: NextRequest) => {
6693
errorPolicy: 'ignore',
6794
});
6895

69-
return new Response(JSON.stringify(response.data), {
96+
return new Response(JSON.stringify(response), {
7097
status: 200,
7198
headers: { 'Content-Type': 'application/json' },
7299
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use client';
2+
3+
import { useEffect, useRef } from 'react';
4+
5+
import { Stream, Streamable, useStreamable } from '@/vibes/soul/lib/streamable';
6+
import { WalletButtonsInitializer } from '~/lib/wallet-buttons';
7+
import { InitializeButtonProps } from '~/lib/wallet-buttons/types';
8+
9+
export const ClientWalletButtons = ({
10+
walletButtonsInitOptions,
11+
cartId,
12+
}: {
13+
walletButtonsInitOptions: Streamable<InitializeButtonProps[]>;
14+
cartId: string;
15+
}) => {
16+
const isMountedRef = useRef(false);
17+
const initButtonProps = useStreamable(walletButtonsInitOptions);
18+
19+
useEffect(() => {
20+
if (!isMountedRef.current && initButtonProps.length) {
21+
isMountedRef.current = true;
22+
23+
const initWalletButtons = async () => {
24+
await new WalletButtonsInitializer().initialize(initButtonProps);
25+
};
26+
27+
void initWalletButtons();
28+
}
29+
}, [cartId, initButtonProps]);
30+
31+
return (
32+
<Stream fallback={null} value={walletButtonsInitOptions}>
33+
{(buttonOptions) => (
34+
<div style={{ display: 'flex', alignItems: 'end', flexDirection: 'column' }}>
35+
{buttonOptions.map((button) =>
36+
button.containerId ? <div id={button.containerId} key={button.containerId} /> : null,
37+
)}
38+
</div>
39+
)}
40+
</Stream>
41+
);
42+
};

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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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.initializeWalletButton(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('wallet-button');
48+
49+
return checkoutButtonModule.createWalletButtonInitializer({
50+
graphQLEndpoint: 'api/wallets/graphql',
51+
});
52+
}
53+
}

core/lib/wallet-buttons/types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
createWalletButtonInitializer(options: {
13+
graphQLEndpoint: string;
14+
}): CheckoutHeadlessButtonInitializer;
15+
}
16+
17+
interface CheckoutHeadlessButtonInitializer {
18+
initializeWalletButton(option: InitializeButtonProps): void;
19+
}
20+
21+
export interface InitializeButtonProps {
22+
[key: string]: unknown;
23+
containerId: string;
24+
methodId: string;
25+
}

0 commit comments

Comments
 (0)