Skip to content

feat(payment): POC headless wallet buttons implementation #1999

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: canary
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use server';

import { BigCommerceGQLError } from '@bigcommerce/catalyst-client';
import { SubmissionResult } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { getLocale, getTranslations } from 'next-intl/server';
import { z } from 'zod';

import { redirect } from '~/i18n/routing';
import { getCartId } from '~/lib/cart';

import { redirectToCheckout } from './redirect-to-checkout';

export const redirectToCheckoutFormAction = async (
_lastResult: SubmissionResult | null,
formData: FormData,
): Promise<SubmissionResult | null> => {
const locale = await getLocale();
const t = await getTranslations('Cart.Errors');

const submission = parseWithZod(formData, { schema: z.object({}) });

const cartId = await getCartId();

if (!cartId) {
return submission.reply({ formErrors: [t('cartNotFound')] });
}

let url;

try {
const { data } = await redirectToCheckout(cartId);

url = data.cart.createCartRedirectUrls.redirectUrls?.redirectedCheckoutUrl;
} catch (error) {
if (error instanceof BigCommerceGQLError) {
return submission.reply({
formErrors: error.errors.map(({ message }) => message),
});
}

if (error instanceof Error) {
return submission.reply({ formErrors: [error.message] });
}

return submission.reply({ formErrors: [t('failedToRedirectToCheckout')] });
}

if (!url) {
return submission.reply({ formErrors: [t('failedToRedirectToCheckout')] });
}

return redirect({ href: url, locale });
};
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
'use server';

import { BigCommerceGQLError } from '@bigcommerce/catalyst-client';
import { SubmissionResult } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { getLocale, getTranslations } from 'next-intl/server';
import { z } from 'zod';

import { getSessionCustomerAccessToken } from '~/auth';
import { client } from '~/client';
import { graphql } from '~/client/graphql';
import { redirect } from '~/i18n/routing';
import { getCartId } from '~/lib/cart';

const CheckoutRedirectMutation = graphql(`
mutation CheckoutRedirectMutation($cartId: String!) {
@@ -24,54 +16,20 @@ const CheckoutRedirectMutation = graphql(`
}
`);

export const redirectToCheckout = async (
_lastResult: SubmissionResult | null,
formData: FormData,
): Promise<SubmissionResult | null> => {
const locale = await getLocale();
const t = await getTranslations('Cart.Errors');

export const redirectToCheckout = async (cartId: string) => {
const customerAccessToken = await getSessionCustomerAccessToken();

const submission = parseWithZod(formData, { schema: z.object({}) });

const cartId = await getCartId();

if (!cartId) {
return submission.reply({ formErrors: [t('cartNotFound')] });
}

let url;

try {
const { data } = await client.fetch({
return await client.fetch({
document: CheckoutRedirectMutation,
variables: { cartId },
fetchOptions: { cache: 'no-store' },
customerAccessToken,
});

url = data.cart.createCartRedirectUrls.redirectUrls?.redirectedCheckoutUrl;
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);

if (error instanceof BigCommerceGQLError) {
return submission.reply({
formErrors: error.errors.map(({ message }) => message),
});
}

if (error instanceof Error) {
return submission.reply({ formErrors: [error.message] });
}

return submission.reply({ formErrors: [t('failedToRedirectToCheckout')] });
throw error;
}

if (!url) {
return submission.reply({ formErrors: [t('failedToRedirectToCheckout')] });
}

return redirect({ href: url, locale });
};
36 changes: 36 additions & 0 deletions core/app/[locale]/(default)/cart/page-data.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client';

import { getSessionCustomerAccessToken } from '~/auth';
import { client } from '~/client';
import { FragmentOf, graphql, VariablesOf } from '~/client/graphql';
@@ -195,6 +197,11 @@ const CartPageQuery = graphql(
discountedAmount {
...MoneyFieldsFragment
}
discounts {
discountedAmount {
...MoneyFieldsFragment
}
}
lineItems {
physicalItems {
...PhysicalItemFragment
@@ -262,6 +269,35 @@ export const getCart = async (variables: Variables) => {
return data;
};

const PaymentWalletsQuery = graphql(`
query PaymentWalletsQuery($filters: PaymentWalletsFilterInput) {
site {
paymentWallets(filter: $filters) {
edges {
node {
entityId
}
}
}
}
}
`);

type PaymentWalletsVariables = VariablesOf<typeof PaymentWalletsQuery>;

export const getPaymentWallets = async (variables: PaymentWalletsVariables) => {
const customerAccessToken = await getSessionCustomerAccessToken();

const { data } = await client.fetch({
document: PaymentWalletsQuery,
customerAccessToken,
fetchOptions: { cache: 'no-store' },
variables,
});

return removeEdgesAndNodes(data.site.paymentWallets).map(({ entityId }) => entityId);
};

export const getShippingCountries = async (geography: FragmentOf<typeof GeographyFragment>) => {
const hasAccessToken = Boolean(process.env.BIGCOMMERCE_ACCESS_TOKEN);
const shippingZones = hasAccessToken ? await getShippingZones() : [];
14 changes: 11 additions & 3 deletions core/app/[locale]/(default)/cart/page.tsx
Original file line number Diff line number Diff line change
@@ -5,12 +5,12 @@ import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/car
import { getCartId } from '~/lib/cart';
import { exists } from '~/lib/utils';

import { redirectToCheckout } from './_actions/redirect-to-checkout';
import { redirectToCheckoutFormAction } from './_actions/redirect-to-checkout-form-action';
import { updateCouponCode } from './_actions/update-coupon-code';
import { updateLineItem } from './_actions/update-line-item';
import { updateShippingInfo } from './_actions/update-shipping-info';
import { CartViewed } from './_components/cart-viewed';
import { getCart, getShippingCountries } from './page-data';
import { getCart, getPaymentWallets, getShippingCountries } from './page-data';

export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('Cart');
@@ -51,6 +51,12 @@ export default async function Cart() {
);
}

const walletButtons = await getPaymentWallets({
filters: {
cartEntityId: cartId,
},
});

const lineItems = [...cart.lineItems.physicalItems, ...cart.lineItems.digitalItems];

const formattedLineItems = lineItems.map((item) => ({
@@ -160,7 +166,8 @@ export default async function Cart() {
},
].filter(exists),
}}
checkoutAction={redirectToCheckout}
cartId={cartId}
checkoutAction={redirectToCheckoutFormAction}
checkoutLabel={t('proceedToCheckout')}
couponCode={{
action: updateCouponCode,
@@ -239,6 +246,7 @@ export default async function Cart() {
}}
summaryTitle={t('CheckoutSummary.title')}
title={t('title')}
walletButtons={walletButtons}
/>
<CartViewed
currencyCode={cart.currencyCode}
32 changes: 32 additions & 0 deletions core/app/api/wallet-buttons/cart-information/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';

import { fetchCart } from '~/client/queries/get-cart';
import { getCurrencyData } from '~/client/queries/get-currency-data';

export const GET = async (request: NextRequest) => {
const cartId = request.nextUrl.searchParams.get('cartId');

if (cartId) {
try {
const cart = await fetchCart(cartId);

const currencyData = await getCurrencyData(cart.data.site.cart?.currencyCode);

return NextResponse.json({
data: {
site: {
...cart.data.site,
...currencyData.data.site,
},
},
});
} catch (error) {
return NextResponse.json({ error });
}
}

return NextResponse.json(
{ error: 'Invalid request body: cartId is not provided' },
{ status: 400 },
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';

import { createPaymentWalletIntent } from '~/client/queries/create-payment-wallet-intent';

interface PaymentWalletIntentResponseBody {
cartId: string;
walletEntityId: string;
}

function isPaymentWalletIntentRequestBody(data: unknown): data is PaymentWalletIntentResponseBody {
return typeof data === 'object' && data !== null && 'walletEntityId' in data && 'cartId' in data;
}

export const POST = async (request: NextRequest) => {
const data: unknown = await request.json();

if (isPaymentWalletIntentRequestBody(data)) {
try {
const dataIntent = await createPaymentWalletIntent(data.cartId, data.walletEntityId);

return NextResponse.json(dataIntent);
} catch (error) {
return NextResponse.json({ error });
}
}

return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
};
22 changes: 22 additions & 0 deletions core/app/api/wallet-buttons/get-checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';

import { getCheckout } from '~/client/queries/get-checkout';

export const GET = async (request: NextRequest) => {
const checkoutId = request.nextUrl.searchParams.get('checkoutId');

if (checkoutId) {
try {
const checkout = await getCheckout(checkoutId);

return NextResponse.json(checkout);
} catch (error) {
return NextResponse.json({ error });
}
}

return NextResponse.json(
{ error: 'Invalid request body: checkoutId is not provided' },
{ status: 400 },
);
};
13 changes: 13 additions & 0 deletions core/app/api/wallet-buttons/get-customer/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';

import { getCustomer } from '~/client/queries/get-customer';

export const GET = async () => {
try {
const customer = await getCustomer();

return NextResponse.json(customer);
} catch (error) {
return NextResponse.json({ error });
}
};
29 changes: 29 additions & 0 deletions core/app/api/wallet-buttons/get-initialization-data/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';

import { getPaymentWalletWithInitializationData } from '~/client/queries/get-payment-wallet-with-initialization-data';

interface PaymentWalletInitializationDataResponseBody {
entityId: string;
}

function isPaymentWalletInitializationDataRequestBody(
data: unknown,
): data is PaymentWalletInitializationDataResponseBody {
return typeof data === 'object' && data !== null && 'entityId' in data;
}

export const POST = async (request: NextRequest) => {
const data: unknown = await request.json();

if (isPaymentWalletInitializationDataRequestBody(data)) {
try {
const initializationData = await getPaymentWalletWithInitializationData(data.entityId);

return NextResponse.json(initializationData);
} catch (error) {
return NextResponse.json({ error });
}
}

return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
};
20 changes: 20 additions & 0 deletions core/app/api/wallet-buttons/redirect-to-checkout-page/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';

import { redirectToCheckout } from '~/app/[locale]/(default)/cart/_actions/redirect-to-checkout';
import { getCartId } from '~/lib/cart';

export const GET = async () => {
try {
const cartId = await getCartId();

if (!cartId) {
return NextResponse.json({ error: 'cart id is not defined' });
}

const redirectData = await redirectToCheckout(cartId);

return NextResponse.json(redirectData);
} catch (error) {
return NextResponse.json({ error });
}
};
Loading