Skip to content
Draft
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .changeset/expose-canonical-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-client": minor
---

Expose `getCanonicalUrl()` as a public method. It returns the BigCommerce-managed storefront URL for a channel, which is where the platform serves that channel's storefront routes. This differs from the channel's configured site URL (`site.settings.url.vanityUrl`) — on a headless channel that points at the headless app — so it is the correct value to use when reaching a platform-served route for the current channel.
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use client';

import Script from 'next/script';
import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';

import { toast } from '@/vibes/soul/primitives/toaster';
import { ACCOUNT_PAYMENTS_MICROAPP_BASE, Manifest } from '~/lib/account-payments/manifest';
import { buildMicroappStyles } from '~/lib/account-payments/styles';

interface Props {
storeContextData: Omit<HeadlessStoreContextDataInterface, 'vaultToken'>;
manifest: Manifest;
}

interface VaultTokenResponse {
vaultToken: string;
}

function isVaultTokenResponse(value: unknown): value is VaultTokenResponse {
return (
typeof value === 'object' &&
value !== null &&
'vaultToken' in value &&
typeof value.vaultToken === 'string'
);
}

class VaultTokenUnauthorizedError extends Error {}

export function AccountPaymentsMicroapp({ storeContextData, manifest }: Props) {
const t = useTranslations('Account.PaymentMethods.Add.Errors');
const [vaultToken, setVaultToken] = useState<string>();
const [scriptsReady, setScriptsReady] = useState(0);
// Guards against calling `renderAccountPayments` more than once for the lifetime of this component instance
const hasRenderedRef = useRef(false);

useEffect(() => {
async function fetchVaultToken() {
const res = await fetch('/api/account/vault-token');

if (res.status === 401) {
throw new VaultTokenUnauthorizedError();
}

if (!res.ok) {
throw new Error(`Vault token request failed with status ${res.status}`);
}

const data: unknown = await res.json();

if (!isVaultTokenResponse(data)) {
throw new Error('Invalid vault token response');
}

setVaultToken(data.vaultToken);
}

fetchVaultToken().catch((error: unknown) => {
toast.error(
error instanceof VaultTokenUnauthorizedError
? t('sessionExpired')
: t('somethingWentWrong'),
);
});
}, [t]);

useEffect(() => {
if (
hasRenderedRef.current ||
!vaultToken ||
scriptsReady < manifest.js.length ||
!window.BigCommerce?.renderAccountPayments
) {
return;
}

hasRenderedRef.current = true;

window.BigCommerce.renderAccountPayments({
styles: buildMicroappStyles(),
storeContextData: { ...storeContextData, vaultToken },
errorHandler: (message: string) => {
toast.error(message);
},
});
}, [vaultToken, scriptsReady, manifest.js.length, storeContextData]);

return (
<>
{manifest.js.map((src) => (
<Script
crossOrigin="anonymous"
integrity={manifest.integrity[src]}
key={src}
onLoad={() => setScriptsReady((n) => n + 1)}
src={`${ACCOUNT_PAYMENTS_MICROAPP_BASE}/${src}`}
strategy="afterInteractive"
/>
))}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { getLocale } from 'next-intl/server';

import { getSessionCustomerAccessToken } from '~/auth';
import { getChannelIdFromLocale } from '~/channels.config';
import { client } from '~/client';
import { graphql } from '~/client/graphql';
import { toAccountPaymentsMicroappCountries } from '~/data-transformers/account-payments-countries';
import { getMicroappManifest } from '~/lib/account-payments/manifest';
import { getVaultInitialization } from '~/lib/account-payments/vault-initialization';
import { getPreferredCurrencyCode } from '~/lib/currency';

// This will be replaced with `site.settings.payments.origin` GQL field once available
const PAYMENTS_URL = 'https://bigpay.service.bcdev';

const AddPaymentPageDataQuery = graphql(`
query AddPaymentPageDataQuery {
customer {
entityId
email
}
geography {
countries {
code
name
statesOrProvinces {
abbreviation
name
}
}
}
site {
currencies {
edges {
node {
code
isDefault
}
}
}
}
}
`);

export async function getAddPaymentPageData({
paymentMethodId,
isInitDataRequired,
}: {
paymentMethodId: string;
isInitDataRequired: boolean;
}) {
const customerAccessToken = await getSessionCustomerAccessToken();

const [{ data }, manifest, storeLocale, preferredCurrencyCode] = await Promise.all([
client.fetch({
document: AddPaymentPageDataQuery,
customerAccessToken,
fetchOptions: { cache: 'no-store' },
}),
getMicroappManifest(),
getLocale(),
getPreferredCurrencyCode(),
]);

const defaultCurrencyCode = data.site.currencies.edges?.find(({ node }) => node.isDefault)?.node
.code;
const currencyCode = preferredCurrencyCode ?? defaultCurrencyCode;

if (!currencyCode) {
throw new Error('No currency code resolved for this session');
}

let providerInitialization: unknown;

if (isInitDataRequired) {
({ providerInitialization } = await getVaultInitialization(paymentMethodId, currencyCode));
}

const customer = data.customer;

if (!customer) {
throw new Error('no authenticated customer');
}

const storeHash = process.env.BIGCOMMERCE_STORE_HASH;

if (!storeHash) {
throw new Error('BIGCOMMERCE_STORE_HASH is not configured');
}

const channelId = getChannelIdFromLocale(storeLocale);

if (!channelId) {
throw new Error('No channel id resolved for this session');
}

const storefrontApiBaseUrl = await client.getCanonicalUrl(channelId);

// vaultToken prop is intentionally omitted here
// It's a secret delivered separately via GET /api/account/vault-token
const storeContextData = {
storeHash,
paymentsUrl: PAYMENTS_URL,
paymentMethodsUrl: '/account/payment-methods',
storefrontApiBaseUrl,
shopperId: customer.entityId.toString(),
customerEmail: customer.email,
countries: toAccountPaymentsMicroappCountries(data.geography.countries ?? []),
storeLocale,
currencyCode,
paymentMethodId,
paymentProviderInitializationData: providerInitialization,
};

return { storeContextData, manifest };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';

import { AccountPaymentsMicroapp } from './_components/account-payments-microapp';
import { getAddPaymentPageData } from './page-data';

interface Props {
params: Promise<{ locale: string; paymentMethodId: string }>;
searchParams: Promise<{ init?: string }>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Account.PaymentMethods.Add' });

return {
title: t('title'),
};
}

export default async function AddPaymentMethod({ params, searchParams }: Props) {
const { locale, paymentMethodId } = await params;

setRequestLocale(locale);

const { init } = await searchParams;

const { storeContextData, manifest } = await getAddPaymentPageData({
paymentMethodId,
isInitDataRequired: init === '1' || init === 'true',
});

return (
<>
<div id="bc-account-payments" />
<AccountPaymentsMicroapp manifest={manifest} storeContextData={storeContextData} />
</>
);
}
36 changes: 36 additions & 0 deletions core/app/[locale]/(default)/account/payment-methods/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// This is a dummy page as a placeholder for payment methods list page that will be implemented later to be the entry point of the "Add payment method" page, and to be the redirect URL after adding a payment method successfully.
// Only for the COP.
import { setRequestLocale } from 'next-intl/server';

import { Link } from '~/components/link';

interface Props {
params: Promise<{ locale: string }>;
}

export default async function PaymentMethodsPage({ params }: Props) {
const { locale } = await params;

setRequestLocale(locale);

return (
<div>
<strong>Payment methods (for POC purpose)</strong>
<br />
<small>
This is a dummy page as a placeholder for payment methods list page that will be implemented
later to be the entry point of the "Add payment method" page, and to be the redirect URL
after adding a payment method successfully.
</small>
<br />
<br />
<Link href="/account/payment-methods/add/squarev2.card?init=1">
Add a card (Square - using provider's widget)
</Link>
<br />
<Link href="/account/payment-methods/add/braintree.card">
Add a card (Braintree - using BigPay's hosted forms)
</Link>
</div>
);
}
29 changes: 29 additions & 0 deletions core/app/api/account/vault-token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';

import { auth } from '~/auth';
import { getVaultAccessToken } from '~/lib/account-payments/get-vault-access-token';

export const dynamic = 'force-dynamic';

// This route is used by the account payments microapp component to retrieve a vault access token for the current shopper session
// to avoid exposing the token to the client-side code as it is a sensitive piece of information.
export async function GET() {
const session = await auth();

if (!session?.user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}

try {
const token = await getVaultAccessToken();

return NextResponse.json(token, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
return NextResponse.json(
{
error: `failed to create vault access token: ${error instanceof Error ? error.message : String(error)}`,
},
{ status: 500 },
);
}
}
24 changes: 24 additions & 0 deletions core/data-transformers/account-payments-countries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
interface GqlState {
abbreviation: string;
name: string;
}

interface GqlCountry {
code: string;
name: string;
statesOrProvinces: GqlState[];
}

// This function converts the GraphQL country data into the format expected by the account payments microapp.
export function toAccountPaymentsMicroappCountries(countries: GqlCountry[]) {
return countries.map((country) => ({
code: country.code,
label: country.name,
value: country.code,
states: country.statesOrProvinces.map((state) => ({
code: state.abbreviation,
name: state.name,
value: state.abbreviation,
})),
}));
}
Loading
Loading