From 1197e973040d253eab43984c391cfc09f1db09e8 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Mon, 3 Aug 2026 15:08:45 -0500 Subject: [PATCH 1/4] support digital fulfillments and line items --- .../checkout-digital-fulfillment.test.tsx | 403 ++++++++++++++++++ .../checkout-layout-appearance.test.tsx | 2 - .../checkout/__tests__/checkout-test-env.tsx | 16 +- .../__tests__/checkout-test-utils.tsx | 15 +- .../__tests__/checkout-tracking.test.tsx | 8 +- .../checkout/address/address-form.tsx | 4 +- .../src/components/checkout/checkout.tsx | 20 +- .../checkout/form/checkout-form-container.tsx | 6 + .../checkout/form/checkout-form.tsx | 87 ++-- .../checkout/line-items/line-items.test.tsx | 78 +++- .../checkout/line-items/line-items.tsx | 207 +++++---- .../components/checkout/notes/notes-form.tsx | 1 + .../checkout/order/use-update-order.ts | 3 +- .../checkout/payment/free-payment-form.tsx | 12 +- .../checkout/payment/payment-form.tsx | 37 +- .../payment/utils/billing-collection.ts | 98 +++++ .../payment/utils/use-confirm-checkout.ts | 9 + .../checkout/pickup/local-pickup.test.ts | 32 ++ .../checkout/pickup/local-pickup.tsx | 10 +- .../should-apply-shipping-method.test.ts | 16 + .../utils/should-apply-shipping-method.ts | 8 +- .../utils/checkout-transformers.test.ts | 55 +++ .../checkout/utils/checkout-transformers.ts | 26 +- .../components/checkout/utils/fulfillment.ts | 61 +++ packages/react/src/components/ui/textarea.tsx | 4 +- 25 files changed, 1036 insertions(+), 182 deletions(-) create mode 100644 packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx create mode 100644 packages/react/src/components/checkout/payment/utils/billing-collection.ts create mode 100644 packages/react/src/components/checkout/pickup/local-pickup.test.ts create mode 100644 packages/react/src/components/checkout/utils/fulfillment.ts diff --git a/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx new file mode 100644 index 00000000..7aeedf9c --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx @@ -0,0 +1,403 @@ +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { + buildLineItem, + clearOperations, + getOperations, + renderCheckout, + typeIntoNamedField, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; +import { getLastConfirmInput } from './checkout-test-fixtures'; + +const expressPaymentMethods = { + card: { processor: 'stripe', checkoutTypes: ['standard'] }, + express: { processor: 'godaddy', checkoutTypes: ['express'] }, +}; + +function buildDigitalLineItem(overrides = {}) { + return buildLineItem({ + id: 'digital-line-item', + type: DeliveryMethods.DIGITAL, + fulfillmentMode: DeliveryMethods.NONE, + ...overrides, + }); +} + +function getInput(name: string) { + return document.querySelector(`input[name="${name}"]`); +} + +function expectBillingNamesOnlyWithPhone() { + expect(getInput('billingFirstName')).toBeInTheDocument(); + expect(getInput('billingLastName')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('(201) 555-1234')).toBeInTheDocument(); + expect(getInput('billingAddressLine1')).not.toBeInTheDocument(); + expect(getInput('billingPostalCode')).not.toBeInTheDocument(); +} + +function expectFullBillingAddress() { + expect(getInput('billingFirstName')).toBeInTheDocument(); + expect(getInput('billingLastName')).toBeInTheDocument(); + expect(getInput('billingAddressLine1')).toBeInTheDocument(); + expect(getInput('billingAdminArea2')).toBeInTheDocument(); + expect(getInput('billingPostalCode')).toBeInTheDocument(); +} + +describe('Digital fulfillment checkout', () => { + it('hides delivery, shipping, pickup, and express for digital-only orders', async () => { + renderCheckout({ + draftOrderOverrides: { + shippingLines: [], + lineItems: [buildDigitalLineItem()], + }, + sessionOverrides: { + enableTaxCollection: true, + paymentMethods: expressPaymentMethods, + }, + }); + await waitForCheckoutReady(); + + expect(screen.queryByText(/^Delivery$/)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Shipping$/)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Pickup$/)).not.toBeInTheDocument(); + expect(screen.getAllByText(/^Billing Address$/).length).toBeGreaterThan(0); + expect( + screen.queryByTestId('mock-godaddy-express-button') + ).not.toBeInTheDocument(); + expect(screen.queryByText(/^OR$/)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /pay now/i })).toBeVisible(); + }); + + it('prevents a custom layout from forcing digital-only delivery or express sections', async () => { + renderCheckout({ + checkoutProps: { + layout: [ + 'express-checkout', + 'delivery', + 'shipping', + 'pickup', + 'payment', + ], + }, + draftOrderOverrides: { + shippingLines: [], + lineItems: [buildDigitalLineItem()], + }, + sessionOverrides: { + paymentMethods: expressPaymentMethods, + }, + }); + await waitForCheckoutReady(); + + expect(screen.queryByText(/^Delivery$/)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Shipping$/)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Pickup$/)).not.toBeInTheDocument(); + expect( + screen.queryByTestId('mock-godaddy-express-button') + ).not.toBeInTheDocument(); + }); + + it('confirms a paid digital-only taxable order with billing tax address and without shipping or pickup fields', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: { address: null }, + lineItems: [buildDigitalLineItem()], + }, + sessionOverrides: { + enableTaxCollection: true, + enableShipping: true, + enableLocalPickup: true, + }, + }); + await waitForCheckoutReady(); + expectFullBillingAddress(); + clearOperations(); + + await user.click(screen.getByRole('button', { name: /pay now/i })); + + await waitForOperation('ConfirmCheckoutSession'); + expect(getLastConfirmInput()).toMatchObject({ + paymentToken: 'test-nonce', + paymentType: 'card', + }); + expect(getLastConfirmInput()).not.toHaveProperty('fulfillmentLocationId'); + expect(getLastConfirmInput()).not.toHaveProperty('fulfillmentStartAt'); + expect(getLastConfirmInput()).not.toHaveProperty('fulfillmentEndAt'); + }); + + it('confirms a free digital-only taxable order with billing tax address and without shipping or pickup fields', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: { address: null }, + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }, + lineItems: [ + buildDigitalLineItem({ + unitAmount: { value: 0, currencyCode: 'USD' }, + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + }, + }), + ], + }, + sessionOverrides: { + enableTaxCollection: true, + enableShipping: true, + enableLocalPickup: true, + }, + }); + await waitForCheckoutReady(); + expectFullBillingAddress(); + clearOperations(); + + await user.click( + screen.getByRole('button', { name: /complete your free order/i }) + ); + + await waitForOperation('ConfirmCheckoutSession'); + expect(getLastConfirmInput()).toMatchObject({ + paymentType: 'offline', + paymentProvider: 'OFFLINE', + }); + expect(getLastConfirmInput()).not.toHaveProperty('fulfillmentLocationId'); + }); + + it('shows full billing address for paid card digital-only orders when tax is disabled', async () => { + renderCheckout({ + draftOrderOverrides: { + shipping: { address: null }, + lineItems: [buildDigitalLineItem()], + }, + sessionOverrides: { + enableTaxCollection: false, + enableShipping: true, + enableLocalPickup: true, + }, + }); + await waitForCheckoutReady(); + + expectFullBillingAddress(); + }); + + it('shows billing names and phone for paid offline digital-only orders when tax is disabled', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: { address: null }, + lineItems: [buildDigitalLineItem()], + }, + sessionOverrides: { + enableTaxCollection: false, + enableShipping: true, + enableLocalPickup: true, + paymentMethods: { + card: { + processor: 'stripe', + checkoutTypes: ['standard'], + }, + offline: { + processor: 'offline', + checkoutTypes: ['standard'], + }, + }, + }, + }); + await waitForCheckoutReady(); + + await user.click(await screen.findByRole('button', { name: /offline/i })); + + expectBillingNamesOnlyWithPhone(); + }); + + it('shows billing names and phone for free digital-only orders when tax is disabled', async () => { + renderCheckout({ + draftOrderOverrides: { + shipping: { address: null }, + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }, + lineItems: [ + buildDigitalLineItem({ + unitAmount: { value: 0, currencyCode: 'USD' }, + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + }, + }), + ], + }, + sessionOverrides: { + enableTaxCollection: false, + enableShipping: true, + enableLocalPickup: true, + }, + }); + await waitForCheckoutReady(); + + expectBillingNamesOnlyWithPhone(); + }); + + it('uses the billing address for digital-only tax recalculation', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shippingLines: [], + lineItems: [buildDigitalLineItem()], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await typeIntoNamedField(user, 'billingPostalCode', '78701'); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + await waitForOperation('CalculateCheckoutSessionTaxes'); + + expect( + getOperations('CalculateCheckoutSessionTaxes').at(-1)?.input + ).toMatchObject({ + destination: expect.objectContaining({ postalCode: '78701' }), + }); + }); + + it('shows express for physical shipping items initially in NONE fulfillment when shipping is enabled', async () => { + renderCheckout({ + draftOrderOverrides: { + lineItems: [ + buildLineItem({ + id: 'physical-line-item', + fulfillmentMode: DeliveryMethods.NONE, + }), + ], + }, + sessionOverrides: { + enableShipping: true, + enableLocalPickup: false, + paymentMethods: expressPaymentMethods, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByTestId('mock-godaddy-express-button') + ).toBeVisible(); + }); + + it('shows express for mixed digital and physical shipping items initially in NONE fulfillment', async () => { + renderCheckout({ + draftOrderOverrides: { + lineItems: [ + buildDigitalLineItem(), + buildLineItem({ + id: 'physical-line-item', + fulfillmentMode: DeliveryMethods.NONE, + }), + ], + }, + sessionOverrides: { + enableShipping: true, + enableLocalPickup: false, + paymentMethods: expressPaymentMethods, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByTestId('mock-godaddy-express-button') + ).toBeVisible(); + }); + + it('hides express for mixed digital and pickup orders', async () => { + renderCheckout({ + draftOrderOverrides: { + lineItems: [ + buildDigitalLineItem(), + buildLineItem({ + id: 'pickup-line-item', + fulfillmentMode: DeliveryMethods.PICKUP, + }), + ], + }, + sessionOverrides: { + enableShipping: true, + enableLocalPickup: true, + paymentMethods: expressPaymentMethods, + }, + }); + await waitForCheckoutReady(); + + expect( + screen.queryByTestId('mock-godaddy-express-button') + ).not.toBeInTheDocument(); + }); + + it('does not let digital NONE lines trigger shipping fulfillment sync', async () => { + renderCheckout({ + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'free-shipping', + requestedProvider: 'unknown', + name: 'Free', + amount: { value: 0, currencyCode: 'USD' }, + discounts: [], + }, + ], + lineItems: [ + buildDigitalLineItem(), + buildLineItem({ id: 'physical-line-item', fulfillmentMode: 'SHIP' }), + ], + }, + sessionOverrides: { + enableShipping: true, + enableLocalPickup: false, + }, + }); + await waitForCheckoutReady(); + + await waitFor(() => { + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 0 + ); + }); + }); + + it('does not let digital NONE lines trigger pickup fulfillment sync', async () => { + renderCheckout({ + draftOrderOverrides: { + lineItems: [ + buildDigitalLineItem(), + buildLineItem({ id: 'pickup-line-item', fulfillmentMode: 'PICKUP' }), + ], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: true, + locations: [], + }, + }); + await waitForCheckoutReady(); + + await waitFor(() => { + expect( + getOperations('ApplyCheckoutSessionFulfillmentLocation') + ).toHaveLength(0); + }); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-layout-appearance.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-layout-appearance.test.tsx index 8574777e..d41a18ea 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-layout-appearance.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-layout-appearance.test.tsx @@ -35,8 +35,6 @@ const targetSlots = [ 'checkout.form.pickup.form.before', 'checkout.form.payment.before', 'checkout.form.payment.after', - 'checkout.form.express-checkout.before', - 'checkout.form.express-checkout.after', 'checkout.summary.line-items.before', 'checkout.summary.line-items.after', 'checkout.summary.totals.subtotal.before', diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-env.tsx b/packages/react/src/components/checkout/__tests__/checkout-test-env.tsx index bffb896a..91d9c2a7 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-test-env.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-test-env.tsx @@ -153,15 +153,23 @@ vi.mock( applicationId: 'test-app-id', }); collect.on('nonce', async event => { + const currentDeliveryMethod = form.getValues('deliveryMethod'); + const pickupData = + currentDeliveryMethod === 'PICKUP' + ? { + fulfillmentLocationId: + form.getValues('pickupLocationId') ?? undefined, + fulfillmentStartAt: + form.getValues('pickupDate') || undefined, + fulfillmentEndAt: form.getValues('pickupTime') || undefined, + } + : {}; await godaddyApi.confirmCheckout( { paymentToken: event?.data?.nonce ?? 'test-nonce', paymentType: 'card', paymentProvider: 'POYNT', - fulfillmentLocationId: - form.getValues('pickupLocationId') ?? undefined, - fulfillmentStartAt: form.getValues('pickupDate') || undefined, - fulfillmentEndAt: form.getValues('pickupTime') || undefined, + ...pickupData, }, session ); diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx index 5faecca1..e6942d6b 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx @@ -572,6 +572,13 @@ function makeDraftOrderResponse() { }; } +function isDigitalMockLineItem(lineItem: DraftOrderLineItem) { + return ( + lineItem.type === DeliveryMethods.DIGITAL || + lineItem.fulfillmentMode === DeliveryMethods.DIGITAL + ); +} + function applyShippingLines(shippingMethods: unknown) { if (!state || !Array.isArray(shippingMethods)) return; @@ -615,7 +622,9 @@ function applyShippingLines(shippingMethods: unknown) { lineItems: state.draftOrder.lineItems?.map(lineItem => ({ ...lineItem, - fulfillmentMode: DeliveryMethods.SHIP, + fulfillmentMode: isDigitalMockLineItem(lineItem) + ? DeliveryMethods.DIGITAL + : DeliveryMethods.SHIP, })) ?? [], }); state.session = { ...state.session, draftOrder: state.draftOrder }; @@ -657,7 +666,9 @@ function applyFulfillmentLocation(fulfillmentLocationId?: string | null) { lineItems: state.draftOrder.lineItems?.map(lineItem => ({ ...lineItem, - fulfillmentMode: DeliveryMethods.PICKUP, + fulfillmentMode: isDigitalMockLineItem(lineItem) + ? DeliveryMethods.DIGITAL + : DeliveryMethods.PICKUP, })) ?? [], shippingLines: [], }; diff --git a/packages/react/src/components/checkout/__tests__/checkout-tracking.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-tracking.test.tsx index a0e241e7..3824230a 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-tracking.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-tracking.test.tsx @@ -315,11 +315,9 @@ describe('Checkout tracking contract', () => { itemCount: 1, currencyCode: 'USD', }); - // TODO(T-601): Current implementation tracks an empty-method impression - // even when the express section is gated off; PRD notes mark this [!]. - tracking.expectTracked(eventIds.expressCheckoutImpression, { - availableMethods: '', - }); + expect( + tracking.getTrackedEvents(eventIds.expressCheckoutImpression) + ).toHaveLength(0); }); it('tracks invalid-submit field names', async () => { diff --git a/packages/react/src/components/checkout/address/address-form.tsx b/packages/react/src/components/checkout/address/address-form.tsx index 73cc8894..dcc66fda 100644 --- a/packages/react/src/components/checkout/address/address-form.tsx +++ b/packages/react/src/components/checkout/address/address-form.tsx @@ -557,7 +557,9 @@ export function AddressForm({ /> - {!onlyNames && ( + {onlyNames ? ( + + ) : ( <> { schema: z.ZodObject | z.ZodEffects; @@ -39,6 +40,10 @@ export function CheckoutFormContainer({ () => mapSkusToItemsDisplay(lineItems, skusMap), [lineItems, skusMap] ); + const fulfillmentSummary = useMemo( + () => getFulfillmentSummary(lineItems ?? order?.lineItems), + [lineItems, order?.lineItems] + ); const formValues = useMemo( () => ({ @@ -78,6 +83,7 @@ export function CheckoutFormContainer({ {...props} schema={schema} items={items} + fulfillmentSummary={fulfillmentSummary} defaultValues={formValues} direction={props.direction} /> diff --git a/packages/react/src/components/checkout/form/checkout-form.tsx b/packages/react/src/components/checkout/form/checkout-form.tsx index c6045576..47eaa8ef 100644 --- a/packages/react/src/components/checkout/form/checkout-form.tsx +++ b/packages/react/src/components/checkout/form/checkout-form.tsx @@ -37,6 +37,7 @@ import { Target } from '@/components/checkout/target/target'; import { TipsForm } from '@/components/checkout/tips/tips-form'; import { DraftOrderTotals } from '@/components/checkout/totals/totals'; import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; +import type { FulfillmentSummary } from '@/components/checkout/utils/fulfillment'; import { checkoutMutationKeys } from '@/components/checkout/utils/query-keys'; import { useIsCheckoutBusy } from '@/components/checkout/utils/use-is-checkout-busy'; import { @@ -63,6 +64,7 @@ interface CheckoutFormProps extends Omit { schema: z.ZodObject | z.ZodEffects; defaultValues?: Pick; items: Product[]; + fulfillmentSummary: FulfillmentSummary; } const ORDER_BACKED_FORM_FIELDS = [ @@ -118,6 +120,7 @@ export function CheckoutForm({ schema, defaultValues, items, + fulfillmentSummary, ...props }: CheckoutFormProps) { const formatCurrency = useFormatCurrency(); @@ -204,9 +207,28 @@ export function CheckoutForm({ const itemCount = items.reduce((sum, item) => sum + (item?.quantity || 0), 0); const isFree = orderTotal <= 0; - const showExpressButtons = subtotal > 0; + const hasExpressCheckoutPaymentMethod = Object.values( + session?.paymentMethods ?? {} + ).some( + method => + method && + Array.isArray(method.checkoutTypes) && + method.checkoutTypes.includes(CheckoutType.EXPRESS) + ); + const showExpressButtons = Boolean( + !isCheckoutDisabled && + subtotal > 0 && + hasExpressCheckoutPaymentMethod && + session?.enableShipping === true && + !fulfillmentSummary.isDigitalOnly && + deliveryMethod !== DeliveryMethods.PURCHASE && + deliveryMethod !== DeliveryMethods.DIGITAL && + !fulfillmentSummary.hasPickupLineItems && + !fulfillmentSummary.hasPurchaseLineItems + ); const enableDelivery = Boolean( - session?.enableShipping || session?.enableLocalPickup + !fulfillmentSummary.isDigitalOnly && + (session?.enableShipping || session?.enableLocalPickup) ); const enableStandaloneNotes = Boolean( session?.enableNotesCollection && !enableDelivery @@ -219,7 +241,6 @@ export function CheckoutForm({ (isShipping && !!session?.enableShipping) || shipping > 0; const showTaxesLine = !!session?.enableTaxCollection || taxTotal > 0; const showFeesLine = feeTotal > 0; - useEffect(() => { if (!totalsLoading && isFree) { form.setValue('paymentMethod', PaymentMethodType.OFFLINE); @@ -260,24 +281,17 @@ export function CheckoutForm({ const totalSavings = Math.abs(orderDiscount + lineItemDiscounts); const [gridTemplateAreas, sectionLength] = React.useMemo(() => { - const { enableTips, paymentMethods } = session || {}; + const { enableTips } = session || {}; if (!props?.layout) { - const enableExpressCheckout = Object.values(paymentMethods ?? {}).some( - method => - method && - Array.isArray(method.checkoutTypes) && - method.checkoutTypes.includes(CheckoutType.EXPRESS) - ); - const deliveryArea = enableDelivery ? deliveryMethodToGridArea[deliveryMethod] : undefined; const defaultAreas = [ - enableExpressCheckout ? 'express-checkout' : undefined, + showExpressButtons ? 'express-checkout' : undefined, 'contact', enableDelivery ? 'delivery' : undefined, - deliveryArea, + enableDelivery ? deliveryArea : undefined, enableTips ? 'tips' : undefined, enableStandaloneNotes ? 'notes' : undefined, 'payment', @@ -294,28 +308,42 @@ export function CheckoutForm({ if (section === 'notes') { return enableStandaloneNotes; } - - if (section !== 'shipping' && section !== 'pickup') { - return true; + if (section === 'express-checkout') { + return showExpressButtons; } - - // Only include shipping section if deliveryMethod is SHIP - if (section === 'shipping' && deliveryMethod === DeliveryMethods.SHIP) { - return true; + if (section === 'delivery') { + return enableDelivery; + } + if (section === 'shipping') { + return enableDelivery && deliveryMethod === DeliveryMethods.SHIP; + } + if (section === 'pickup') { + return enableDelivery && deliveryMethod === DeliveryMethods.PICKUP; } - // Only include pickup section if deliveryMethod is PICKUP - return section === 'pickup' && deliveryMethod === DeliveryMethods.PICKUP; + return true; }); // Get all available section values const sectionValues = Object.values(LayoutSections); const missingLayoutSections = sectionValues.filter(section => { - if (section === 'shipping' && deliveryMethod !== DeliveryMethods.SHIP) { + if (section === 'express-checkout' && !showExpressButtons) { + return false; + } + if (section === 'delivery' && !enableDelivery) { + return false; + } + if ( + section === 'shipping' && + (!enableDelivery || deliveryMethod !== DeliveryMethods.SHIP) + ) { return false; } - if (section === 'pickup' && deliveryMethod !== DeliveryMethods.PICKUP) { + if ( + section === 'pickup' && + (!enableDelivery || deliveryMethod !== DeliveryMethods.PICKUP) + ) { return false; } @@ -338,6 +366,7 @@ export function CheckoutForm({ session, enableDelivery, enableStandaloneNotes, + showExpressButtons, ]); React.useEffect(() => { @@ -405,7 +434,7 @@ export function CheckoutForm({ gridTemplateAreas, }} > - {!isCheckoutDisabled && showExpressButtons ? ( + {showExpressButtons ? ( @@ -420,7 +449,7 @@ export function CheckoutForm({ - {session?.enableShipping || session?.enableLocalPickup ? ( + {enableDelivery ? ( @@ -439,7 +468,9 @@ export function CheckoutForm({ ) : null} - {isPickup && session?.enableLocalPickup ? ( + {enableDelivery && + isPickup && + session?.enableLocalPickup ? ( ) : null} - {isShipping && session?.enableShipping ? ( + {enableDelivery && isShipping && session?.enableShipping ? ( = {}): Product { return { @@ -39,7 +43,79 @@ function LineItemsHost({ onRemove }: { onRemove: (id: string) => void }) { ); } +describe('getDisplayableImageSrc', () => { + it.each([ + [ + 'https://img.example.test/product.jpg', + 'https://img.example.test/product.jpg', + ], + [ + 'http://img.example.test/product.jpg', + 'http://img.example.test/product.jpg', + ], + ['/images/product.jpg', '/images/product.jpg'], + ['data:image/png;base64,abc', 'data:image/png;base64,abc'], + [ + 'blob:https://example.test/asset-id', + 'blob:https://example.test/asset-id', + ], + ['019fc87d-cd1e-7266-94ac-ca870c947819', undefined], + ['', undefined], + ])('maps %s to %s', (input, expected) => { + expect(getDisplayableImageSrc(input)).toBe(expected); + }); +}); + describe('DraftOrderLineItems', () => { + it('uses the image placeholder when productAssetUrl is an asset id instead of a URL', () => { + render( + + + + ); + + expect( + screen.getByTestId('line-item-image-placeholder') + ).toBeInTheDocument(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('renders an image when productAssetUrl is a URL', () => { + render( + + + + ); + + expect(screen.getByRole('img', { name: 'Image Product' })).toHaveAttribute( + 'src', + 'https://img.example.test/product.jpg' + ); + expect( + screen.queryByTestId('line-item-image-placeholder') + ).not.toBeInTheDocument(); + }); + it('calls onRemoveFromCart with the line item id and removes host-owned items from the rendered list', async () => { const onRemove = vi.fn(); const user = userEvent.setup(); diff --git a/packages/react/src/components/checkout/line-items/line-items.tsx b/packages/react/src/components/checkout/line-items/line-items.tsx index 07ec2d12..420ad972 100644 --- a/packages/react/src/components/checkout/line-items/line-items.tsx +++ b/packages/react/src/components/checkout/line-items/line-items.tsx @@ -64,6 +64,28 @@ export interface DraftOrderLineItemsProps { removingItemId?: string; } +export function getDisplayableImageSrc(image?: string | null) { + const trimmedImage = image?.trim(); + if (!trimmedImage) return undefined; + + if ( + trimmedImage.startsWith('/') || + trimmedImage.startsWith('data:image/') || + trimmedImage.startsWith('blob:') + ) { + return trimmedImage; + } + + try { + const url = new URL(trimmedImage); + return url.protocol === 'http:' || url.protocol === 'https:' + ? trimmedImage + : undefined; + } catch { + return undefined; + } +} + export function DraftOrderLineItems({ items, currencyCode = 'USD', @@ -78,103 +100,112 @@ export function DraftOrderLineItems({ return (
- {items.map(item => ( -
- {item.image ? ( -
- {item.name} -
- ) : ( -
- -
- )} -
-
-
- - {item.name} - {item?.selectedOptions?.length ? ( - - ( - {item.selectedOptions - .flatMap(option => option.values || []) - .join(' / ')} + {items.map(item => { + const imageSrc = getDisplayableImageSrc(item.image); + + return ( +
+ {imageSrc ? ( +
+ {item.name} +
+ ) : ( +
+ +
+ )} +
+
+
+ + {item.name} + {item?.selectedOptions?.length ? ( + + ( + {item.selectedOptions + .flatMap(option => option.values || []) + .join(' / ')} + ) + + ) : null} + + + {item?.addons?.map( + (addon: SelectedAddon, index: number) => ( + + {addon.attribute}: + {addon.values?.map(value => ( + + {value.name} + + ))} + ) - - ) : null} - - - {item?.addons?.map((addon: SelectedAddon, index: number) => ( - - {addon.attribute}: - {addon.values?.map(value => ( - + + {t.general.quantity}: {item.quantity} + {onRemoveFromCart ? ( + <> + ·{' '} + + + ) : null} + + {item.notes?.length ? ( + + {t.lineItems.note} + {item.notes?.map(note => ( + {note.content} ))} - ))} - - - {t.general.quantity}: {item.quantity} - {onRemoveFromCart ? ( - <> - ·{' '} - - ) : null} - - {item.notes?.length ? ( - - {t.lineItems.note} - {item.notes?.map(note => ( - {note.content} - ))} - +
+ {item.originalPrice != null && item.quantity ? ( +
+
+ + {formatCurrency({ + amount: item.originalPrice * item.quantity, + currencyCode, + inputInMinorUnits, + })} + +
+
) : null}
- {item.originalPrice != null && item.quantity ? ( -
-
- - {formatCurrency({ - amount: item.originalPrice * item.quantity, - currencyCode, - inputInMinorUnits, - })} - -
-
- ) : null}
-
- ))} + ); + })}
); diff --git a/packages/react/src/components/checkout/notes/notes-form.tsx b/packages/react/src/components/checkout/notes/notes-form.tsx index 320877d9..db203193 100644 --- a/packages/react/src/components/checkout/notes/notes-form.tsx +++ b/packages/react/src/components/checkout/notes/notes-form.tsx @@ -80,6 +80,7 @@ export function NotesForm() { {t.general.notes}