diff --git a/.changeset/cute-spies-talk.md b/.changeset/cute-spies-talk.md new file mode 100644 index 0000000000..67a96a2f80 --- /dev/null +++ b/.changeset/cute-spies-talk.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Use optimistic checkout redirect URL loading strategy to improve checkout load time diff --git a/core/app/[locale]/(default)/cart/_actions/generate-checkout-url.ts b/core/app/[locale]/(default)/cart/_actions/generate-checkout-url.ts new file mode 100644 index 0000000000..3e41930bc2 --- /dev/null +++ b/core/app/[locale]/(default)/cart/_actions/generate-checkout-url.ts @@ -0,0 +1,65 @@ +'use server'; + +import { getSessionCustomerAccessToken } from '~/auth'; +import { getChannelIdFromLocale } from '~/channels.config'; +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { getVisitIdCookie, getVisitorIdCookie } from '~/lib/analytics/bigcommerce'; + +const CheckoutRedirectMutation = graphql(` + mutation CheckoutRedirectMutation($cartId: String!, $visitId: UUID, $visitorId: UUID) { + cart { + createCartRedirectUrls( + input: { cartEntityId: $cartId, visitId: $visitId, visitorId: $visitorId } + ) { + errors { + ... on NotFoundError { + __typename + } + } + redirectUrls { + redirectedCheckoutUrl + } + } + } + } +`); + +export interface GenerateCheckoutUrlResult { + url: string | null; + error?: string; +} + +export async function generateCheckoutUrl( + cartId: string, + locale: string, +): Promise { + try { + const customerAccessToken = await getSessionCustomerAccessToken(); + const channelId = getChannelIdFromLocale(locale); + const visitId = await getVisitIdCookie(); + const visitorId = await getVisitorIdCookie(); + + const { data } = await client.fetch({ + document: CheckoutRedirectMutation, + variables: { cartId, visitId, visitorId }, + fetchOptions: { cache: 'no-store' }, + customerAccessToken, + channelId, + }); + + if ( + data.cart.createCartRedirectUrls.errors.length > 0 || + !data.cart.createCartRedirectUrls.redirectUrls + ) { + return { url: null, error: 'Failed to generate checkout URL' }; + } + + return { url: data.cart.createCartRedirectUrls.redirectUrls.redirectedCheckoutUrl }; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error generating checkout URL:', error); + + return { url: null, error: 'Failed to generate checkout URL' }; + } +} diff --git a/core/app/[locale]/(default)/cart/_components/optimized-cart.tsx b/core/app/[locale]/(default)/cart/_components/optimized-cart.tsx new file mode 100644 index 0000000000..75e79ddda8 --- /dev/null +++ b/core/app/[locale]/(default)/cart/_components/optimized-cart.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { SubmissionResult } from '@conform-to/react'; +import { useEffect, useState } from 'react'; + +import { + CartClient as CartComponent, + CartLineItem, + CartProps, +} from '@/vibes/soul/sections/cart/client'; +import { useRouter } from '~/i18n/routing'; +import { isCheckoutUrlValid } from '~/lib/checkout-url-utils'; + +type CheckoutAction = ( + lastResult: SubmissionResult | null, + formData: FormData, +) => Promise; + +interface OptimizedCartProps { + preGeneratedUrl: string | null; + fallbackAction: CheckoutAction; + cartProps: CartProps; +} + +export function OptimizedCart({ + preGeneratedUrl, + fallbackAction, + cartProps, +}: OptimizedCartProps) { + const router = useRouter(); + const [checkoutUrl, setCheckoutUrl] = useState(preGeneratedUrl); + const [pageLoadTime] = useState(() => Date.now()); // Capture when this component first mounted + + // Update the stored URL if a new one is provided + useEffect(() => { + setCheckoutUrl(preGeneratedUrl); + }, [preGeneratedUrl]); + + // Custom action that checks URL validity before redirecting + const optimizedCheckoutAction: CheckoutAction = async (lastResult, formData) => { + // Check if we have a valid pre-generated URL (accounting for clock skew and page load time) + if (checkoutUrl && isCheckoutUrlValid(checkoutUrl, pageLoadTime)) { + // Direct client-side redirect for better performance + router.push(checkoutUrl); + + // Return null to prevent further processing + return null; + } + + // Fall back to the original server action if URL is invalid or missing + return fallbackAction(lastResult, formData); + }; + + return ; +} diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index df61e28227..99edff1378 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -2,18 +2,20 @@ import { Metadata } from 'next'; import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'; import { Streamable } from '@/vibes/soul/lib/streamable'; -import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/cart'; +import { CartEmptyState } from '@/vibes/soul/sections/cart'; import { CartAnalyticsProvider } from '~/app/[locale]/(default)/cart/_components/cart-analytics-provider'; import { getCartId } from '~/lib/cart'; import { exists } from '~/lib/utils'; +import { generateCheckoutUrl } from './_actions/generate-checkout-url'; import { redirectToCheckout } from './_actions/redirect-to-checkout'; 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 { CheckoutPreconnect } from './_components/checkout-preconnect'; +import { OptimizedCart } from './_components/optimized-cart'; import { getCart, getShippingCountries } from './page-data'; +import { CheckoutPreconnect } from './_components/checkout-preconnect'; interface Props { params: Promise<{ locale: string }>; @@ -153,134 +155,142 @@ export default async function Cart({ params }: Props) { const showShippingForm = shippingConsignment?.address && !shippingConsignment.selectedShippingOption; + // Pre-generate checkout URL for performance optimization + const { url: preGeneratedCheckoutUrl } = await generateCheckoutUrl(cartId, locale); const checkoutUrl = data.site.settings?.url.checkoutUrl; return ( <> getAnalyticsData(cartId))}> {checkoutUrl ? : null} - 0 + 0 + ? { + label: t('CheckoutSummary.discounts'), + value: `-${format.number(cart.discountedAmount.value, { + style: 'currency', + currency: cart.currencyCode, + })}`, + } + : null, + totalCouponDiscount > 0 + ? { + label: t('CheckoutSummary.CouponCode.couponCode'), + value: `-${format.number(totalCouponDiscount, { + style: 'currency', + currency: cart.currencyCode, + })}`, + } + : null, + checkout?.taxTotal && { + label: t('CheckoutSummary.tax'), + value: format.number(checkout.taxTotal.value, { + style: 'currency', + currency: cart.currencyCode, + }), + }, + ].filter(exists), + }, + // Placeholder action - will be overridden by OptimizedCart + checkoutAction: redirectToCheckout, + checkoutLabel: t('proceedToCheckout'), + couponCode: { + action: updateCouponCode, + couponCodes: checkout?.coupons.map((coupon) => coupon.code) ?? [], + ctaLabel: t('CheckoutSummary.CouponCode.apply'), + label: t('CheckoutSummary.CouponCode.couponCode'), + removeLabel: t('CheckoutSummary.CouponCode.removeCouponCode'), + }, + decrementLineItemLabel: t('decrement'), + deleteLineItemLabel: t('removeItem'), + emptyState: { + title: t('Empty.title'), + subtitle: t('Empty.subtitle'), + cta: { label: t('Empty.cta'), href: '/shop-all' }, + }, + incrementLineItemLabel: t('increment'), + // Type assertion is needed due to the extended LineItem type in updateLineItem + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any + lineItemAction: updateLineItem as any, + shipping: { + action: updateShippingInfo, + countries, + states: statesOrProvinces, + address: shippingConsignment?.address ? { - label: t('CheckoutSummary.discounts'), - value: `-${format.number(cart.discountedAmount.value, { - style: 'currency', - currency: cart.currencyCode, - })}`, + country: shippingConsignment.address.countryCode, + city: + shippingConsignment.address.city !== '' + ? (shippingConsignment.address.city ?? undefined) + : undefined, + state: + shippingConsignment.address.stateOrProvince !== '' + ? (shippingConsignment.address.stateOrProvince ?? undefined) + : undefined, + postalCode: + shippingConsignment.address.postalCode !== '' + ? (shippingConsignment.address.postalCode ?? undefined) + : undefined, } - : null, - totalCouponDiscount > 0 + : undefined, + shippingOptions: shippingConsignment?.availableShippingOptions + ? shippingConsignment.availableShippingOptions.map((option) => ({ + label: option.description, + value: option.entityId, + price: format.number(option.cost.value, { + style: 'currency', + currency: checkout?.cart?.currencyCode, + }), + })) + : undefined, + shippingOption: shippingConsignment?.selectedShippingOption ? { - label: t('CheckoutSummary.CouponCode.couponCode'), - value: `-${format.number(totalCouponDiscount, { + value: shippingConsignment.selectedShippingOption.entityId, + label: shippingConsignment.selectedShippingOption.description, + price: format.number(shippingConsignment.selectedShippingOption.cost.value, { style: 'currency', - currency: cart.currencyCode, - })}`, + currency: checkout?.cart?.currencyCode, + }), } - : null, - checkout?.taxTotal && { - label: t('CheckoutSummary.tax'), - value: format.number(checkout.taxTotal.value, { - style: 'currency', - currency: cart.currencyCode, - }), - }, - ].filter(exists), - }} - checkoutAction={redirectToCheckout} - checkoutLabel={t('proceedToCheckout')} - couponCode={{ - action: updateCouponCode, - couponCodes: checkout?.coupons.map((coupon) => coupon.code) ?? [], - ctaLabel: t('CheckoutSummary.CouponCode.apply'), - label: t('CheckoutSummary.CouponCode.couponCode'), - removeLabel: t('CheckoutSummary.CouponCode.removeCouponCode'), - }} - decrementLineItemLabel={t('decrement')} - deleteLineItemLabel={t('removeItem')} - emptyState={{ - title: t('Empty.title'), - subtitle: t('Empty.subtitle'), - cta: { label: t('Empty.cta'), href: '/shop-all' }, - }} - incrementLineItemLabel={t('increment')} - key={`${cart.entityId}-${cart.version}`} - lineItemAction={updateLineItem} - shipping={{ - action: updateShippingInfo, - countries, - states: statesOrProvinces, - address: shippingConsignment?.address - ? { - country: shippingConsignment.address.countryCode, - city: - shippingConsignment.address.city !== '' - ? (shippingConsignment.address.city ?? undefined) - : undefined, - state: - shippingConsignment.address.stateOrProvince !== '' - ? (shippingConsignment.address.stateOrProvince ?? undefined) - : undefined, - postalCode: - shippingConsignment.address.postalCode !== '' - ? (shippingConsignment.address.postalCode ?? undefined) - : undefined, - } - : undefined, - shippingOptions: shippingConsignment?.availableShippingOptions - ? shippingConsignment.availableShippingOptions.map((option) => ({ - label: option.description, - value: option.entityId, - price: format.number(option.cost.value, { - style: 'currency', - currency: checkout?.cart?.currencyCode, - }), - })) - : undefined, - shippingOption: shippingConsignment?.selectedShippingOption - ? { - value: shippingConsignment.selectedShippingOption.entityId, - label: shippingConsignment.selectedShippingOption.description, - price: format.number(shippingConsignment.selectedShippingOption.cost.value, { - style: 'currency', - currency: checkout?.cart?.currencyCode, - }), - } - : undefined, - showShippingForm, - shippingLabel: t('CheckoutSummary.Shipping.shipping'), - addLabel: t('CheckoutSummary.Shipping.add'), - changeLabel: t('CheckoutSummary.Shipping.change'), - countryLabel: t('CheckoutSummary.Shipping.country'), - cityLabel: t('CheckoutSummary.Shipping.city'), - stateLabel: t('CheckoutSummary.Shipping.state'), - postalCodeLabel: t('CheckoutSummary.Shipping.postalCode'), - updateShippingOptionsLabel: t('CheckoutSummary.Shipping.updatedShippingOptions'), - viewShippingOptionsLabel: t('CheckoutSummary.Shipping.viewShippingOptions'), - cancelLabel: t('CheckoutSummary.Shipping.cancel'), - editAddressLabel: t('CheckoutSummary.Shipping.editAddress'), - shippingOptionsLabel: t('CheckoutSummary.Shipping.shippingOptions'), - updateShippingLabel: t('CheckoutSummary.Shipping.updateShipping'), - addShippingLabel: t('CheckoutSummary.Shipping.addShipping'), - noShippingOptionsLabel: t('CheckoutSummary.Shipping.noShippingOptions'), + : undefined, + showShippingForm, + shippingLabel: t('CheckoutSummary.Shipping.shipping'), + addLabel: t('CheckoutSummary.Shipping.add'), + changeLabel: t('CheckoutSummary.Shipping.change'), + countryLabel: t('CheckoutSummary.Shipping.country'), + cityLabel: t('CheckoutSummary.Shipping.city'), + stateLabel: t('CheckoutSummary.Shipping.state'), + postalCodeLabel: t('CheckoutSummary.Shipping.postalCode'), + updateShippingOptionsLabel: t('CheckoutSummary.Shipping.updatedShippingOptions'), + viewShippingOptionsLabel: t('CheckoutSummary.Shipping.viewShippingOptions'), + cancelLabel: t('CheckoutSummary.Shipping.cancel'), + editAddressLabel: t('CheckoutSummary.Shipping.editAddress'), + shippingOptionsLabel: t('CheckoutSummary.Shipping.shippingOptions'), + updateShippingLabel: t('CheckoutSummary.Shipping.updateShipping'), + addShippingLabel: t('CheckoutSummary.Shipping.addShipping'), + noShippingOptionsLabel: t('CheckoutSummary.Shipping.noShippingOptions'), + }, + summaryTitle: t('CheckoutSummary.title'), + title: t('title'), }} - summaryTitle={t('CheckoutSummary.title')} - title={t('title')} + fallbackAction={redirectToCheckout} + preGeneratedUrl={preGeneratedCheckoutUrl} /> toleranceSeconds; +} diff --git a/core/package.json b/core/package.json index fa630f1902..4d613ef522 100644 --- a/core/package.json +++ b/core/package.json @@ -46,7 +46,7 @@ "gql.tada": "^1.8.10", "graphql": "^16.11.0", "isomorphic-dompurify": "^2.25.0", - "jose": "^5.10.0", + "jose": "^6.0.12", "lodash.debounce": "^4.0.8", "lru-cache": "^11.1.0", "lucide-react": "^0.474.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af596a2554..1f46650f1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,8 +135,8 @@ importers: specifier: ^2.25.0 version: 2.25.0 jose: - specifier: ^5.10.0 - version: 5.10.0 + specifier: ^6.0.12 + version: 6.0.12 lodash.debounce: specifier: ^4.0.8 version: 4.0.8 @@ -6539,6 +6539,9 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.0.12: + resolution: {integrity: sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -16904,6 +16907,8 @@ snapshots: jose@5.10.0: {} + jose@6.0.12: {} + joycon@3.1.1: {} jpeg-js@0.4.4: {}