diff --git a/packages/localizations/package.json b/packages/localizations/package.json index 4f0172f3..5ff23631 100644 --- a/packages/localizations/package.json +++ b/packages/localizations/package.json @@ -8,9 +8,7 @@ "type": "git", "url": "git+https://github.com/godaddy/javascript.git" }, - "files": [ - "dist" - ], + "files": ["dist"], "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index d9871810..1842a654 100644 --- a/packages/localizations/src/deDe.ts +++ b/packages/localizations/src/deDe.ts @@ -312,6 +312,8 @@ export const deDe = { 'Berechnung der Bestellsummen fehlgeschlagen', SHIPPING_METHOD_APPLICATION_FAILED: 'Anwenden der Versandmethode fehlgeschlagen', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'Versandmethoden gelten nicht für rein digitale Bestellungen', SKU_FETCH_FAILED: 'Abrufen der SKUs fehlgeschlagen', SHIPPING_RATES_CALCULATION_FAILED: 'Berechnung der Versandkosten fehlgeschlagen', diff --git a/packages/localizations/src/enAu.ts b/packages/localizations/src/enAu.ts index cf205cb9..7c8eb760 100644 --- a/packages/localizations/src/enAu.ts +++ b/packages/localizations/src/enAu.ts @@ -292,6 +292,8 @@ export const enAu = { ORDER_TOTALS_UPDATE_FAILED: 'Failed to update order totals', ORDER_TOTAL_CALCULATION_FAILED: 'Failed to calculate order totals', SHIPPING_METHOD_APPLICATION_FAILED: 'Failed to apply shipping method', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + "Shipping methods don't apply to digital-only orders", SKU_FETCH_FAILED: 'Failed to fetch SKUs', SHIPPING_RATES_CALCULATION_FAILED: 'Failed to calculate shipping rates', SHIPPING_METHOD_NOT_FOUND: 'Shipping method not found', diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts index 881fd736..e87a00a5 100644 --- a/packages/localizations/src/enIe.ts +++ b/packages/localizations/src/enIe.ts @@ -292,6 +292,8 @@ export const enIe = { ORDER_TOTALS_UPDATE_FAILED: 'Failed to update order totals', ORDER_TOTAL_CALCULATION_FAILED: 'Failed to calculate order totals', SHIPPING_METHOD_APPLICATION_FAILED: 'Failed to apply shipping method', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + "Shipping methods don't apply to digital-only orders", SKU_FETCH_FAILED: 'Failed to fetch SKUs', SHIPPING_RATES_CALCULATION_FAILED: 'Failed to calculate shipping rates', SHIPPING_METHOD_NOT_FOUND: 'Shipping method not found', diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts index f485b21c..ffef4e36 100644 --- a/packages/localizations/src/enUs.ts +++ b/packages/localizations/src/enUs.ts @@ -292,6 +292,8 @@ export const enUs = { ORDER_TOTALS_UPDATE_FAILED: 'Failed to update order totals', ORDER_TOTAL_CALCULATION_FAILED: 'Failed to calculate order totals', SHIPPING_METHOD_APPLICATION_FAILED: 'Failed to apply shipping method', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + "Shipping methods don't apply to digital-only orders", SKU_FETCH_FAILED: 'Failed to fetch SKUs', SHIPPING_RATES_CALCULATION_FAILED: 'Failed to calculate shipping rates', SHIPPING_METHOD_NOT_FOUND: 'Shipping method not found', diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts index 9cfd4cdd..cf8a2e19 100644 --- a/packages/localizations/src/esAr.ts +++ b/packages/localizations/src/esAr.ts @@ -298,6 +298,8 @@ export const esAr = { ORDER_TOTALS_UPDATE_FAILED: 'Error al actualizar totales del pedido', ORDER_TOTAL_CALCULATION_FAILED: 'Error al calcular totales del pedido', SHIPPING_METHOD_APPLICATION_FAILED: 'Error al aplicar método de envío', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'Los métodos de envío no aplican a pedidos solo digitales', SKU_FETCH_FAILED: 'Error al obtener SKUs', SHIPPING_RATES_CALCULATION_FAILED: 'Error al calcular tarifas de envío', SHIPPING_METHOD_NOT_FOUND: 'Método de envío no encontrado', diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts index d7defa1b..72b4ccc5 100644 --- a/packages/localizations/src/esCl.ts +++ b/packages/localizations/src/esCl.ts @@ -300,6 +300,8 @@ export const esCl = { ORDER_TOTALS_UPDATE_FAILED: 'Error al actualizar totales de pedido', ORDER_TOTAL_CALCULATION_FAILED: 'Error al calcular totales de pedido', SHIPPING_METHOD_APPLICATION_FAILED: 'Error al aplicar método de envío', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'Los métodos de envío no aplican a pedidos solo digitales', SKU_FETCH_FAILED: 'Error al obtener SKUs', SHIPPING_RATES_CALCULATION_FAILED: 'Error al calcular tarifas de envío', SHIPPING_METHOD_NOT_FOUND: 'Método de envío no encontrado', diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts index ac8d83a7..e78cdae5 100644 --- a/packages/localizations/src/esCo.ts +++ b/packages/localizations/src/esCo.ts @@ -298,6 +298,8 @@ export const esCo = { ORDER_TOTALS_UPDATE_FAILED: 'Error al actualizar totales del pedido', ORDER_TOTAL_CALCULATION_FAILED: 'Error al calcular totales del pedido', SHIPPING_METHOD_APPLICATION_FAILED: 'Error al aplicar método de envío', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'Los métodos de envío no aplican a pedidos solo digitales', SKU_FETCH_FAILED: 'Error al obtener SKUs', SHIPPING_RATES_CALCULATION_FAILED: 'Error al calcular tarifas de envío', SHIPPING_METHOD_NOT_FOUND: 'Método de envío no encontrado', diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts index 07804024..34f1005d 100644 --- a/packages/localizations/src/esEs.ts +++ b/packages/localizations/src/esEs.ts @@ -302,6 +302,8 @@ export const esEs = { ORDER_TOTAL_CALCULATION_FAILED: 'No se pudo calcular los totales del pedido', SHIPPING_METHOD_APPLICATION_FAILED: 'No se pudo aplicar el método de envío', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'Los métodos de envío no se aplican a pedidos solo digitales', SKU_FETCH_FAILED: 'No se pudieron obtener los SKUs', SHIPPING_RATES_CALCULATION_FAILED: 'No se pudieron calcular las tarifas de envío', diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts index a1d49236..94447505 100644 --- a/packages/localizations/src/esMx.ts +++ b/packages/localizations/src/esMx.ts @@ -299,6 +299,8 @@ export const esMx = { ORDER_TOTALS_UPDATE_FAILED: 'Error al actualizar totales del pedido', ORDER_TOTAL_CALCULATION_FAILED: 'Error al calcular totales del pedido', SHIPPING_METHOD_APPLICATION_FAILED: 'Error al aplicar método de envío', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'Los métodos de envío no aplican a pedidos solo digitales', SKU_FETCH_FAILED: 'Error al obtener SKUs', SHIPPING_RATES_CALCULATION_FAILED: 'Error al calcular tarifas de envío', SHIPPING_METHOD_NOT_FOUND: 'Método de envío no encontrado', diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts index 88dfcfcb..b5145772 100644 --- a/packages/localizations/src/esPe.ts +++ b/packages/localizations/src/esPe.ts @@ -298,6 +298,8 @@ export const esPe = { ORDER_TOTALS_UPDATE_FAILED: 'Error al actualizar totales del pedido', ORDER_TOTAL_CALCULATION_FAILED: 'Error al calcular totales del pedido', SHIPPING_METHOD_APPLICATION_FAILED: 'Error al aplicar método de envío', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'Los métodos de envío no aplican a pedidos solo digitales', SKU_FETCH_FAILED: 'Error al obtener SKUs', SHIPPING_RATES_CALCULATION_FAILED: 'Error al calcular tarifas de envío', SHIPPING_METHOD_NOT_FOUND: 'Método de envío no encontrado', diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts index a951a673..4d0089e1 100644 --- a/packages/localizations/src/esUs.ts +++ b/packages/localizations/src/esUs.ts @@ -298,6 +298,8 @@ export const esUs = { ORDER_TOTALS_UPDATE_FAILED: 'Error al actualizar totales del pedido', ORDER_TOTAL_CALCULATION_FAILED: 'Error al calcular totales del pedido', SHIPPING_METHOD_APPLICATION_FAILED: 'Error al aplicar método de envío', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'Los métodos de envío no aplican a pedidos solo digitales', SKU_FETCH_FAILED: 'Error al obtener SKUs', SHIPPING_RATES_CALCULATION_FAILED: 'Error al calcular tarifas de envío', SHIPPING_METHOD_NOT_FOUND: 'Método de envío no encontrado', diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts index 5268a2d9..99c4888b 100644 --- a/packages/localizations/src/frCa.ts +++ b/packages/localizations/src/frCa.ts @@ -312,6 +312,8 @@ export const frCa = { ORDER_TOTAL_CALCULATION_FAILED: 'Échec du calcul des totaux de commande', SHIPPING_METHOD_APPLICATION_FAILED: "Échec de l'application de la méthode d'expédition", + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + "Les méthodes d'expédition ne s'appliquent pas aux commandes uniquement numériques", SKU_FETCH_FAILED: 'Échec de la récupération des SKU', SHIPPING_RATES_CALCULATION_FAILED: "Échec du calcul des tarifs d'expédition", diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts index c59d74a4..c8345087 100644 --- a/packages/localizations/src/frFr.ts +++ b/packages/localizations/src/frFr.ts @@ -312,6 +312,8 @@ export const frFr = { ORDER_TOTAL_CALCULATION_FAILED: 'Échec du calcul des totaux de la commande', SHIPPING_METHOD_APPLICATION_FAILED: "Échec de l'application de la méthode d'expédition", + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + "Les méthodes d'expédition ne s'appliquent pas aux commandes uniquement numériques", SKU_FETCH_FAILED: 'Échec de la récupération des SKU', SHIPPING_RATES_CALCULATION_FAILED: "Échec du calcul des tarifs d'expédition", diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts index 948417cb..41c794e4 100644 --- a/packages/localizations/src/idId.ts +++ b/packages/localizations/src/idId.ts @@ -293,6 +293,8 @@ export const idId = { ORDER_TOTALS_UPDATE_FAILED: 'Gagal memperbarui total pesanan', ORDER_TOTAL_CALCULATION_FAILED: 'Gagal menghitung total pesanan', SHIPPING_METHOD_APPLICATION_FAILED: 'Gagal menerapkan metode pengiriman', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'Metode pengiriman tidak berlaku untuk pesanan digital saja', SKU_FETCH_FAILED: 'Gagal mengambil SKU', SHIPPING_RATES_CALCULATION_FAILED: 'Gagal menghitung tarif pengiriman', SHIPPING_METHOD_NOT_FOUND: 'Metode pengiriman tidak ditemukan', diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts index 0e735589..89525d46 100644 --- a/packages/localizations/src/itIt.ts +++ b/packages/localizations/src/itIt.ts @@ -310,6 +310,8 @@ export const itIt = { ORDER_TOTAL_CALCULATION_FAILED: "Errore nel calcolo dei totali dell'ordine", SHIPPING_METHOD_APPLICATION_FAILED: "Errore nell'applicazione del metodo di spedizione", + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'I metodi di spedizione non si applicano agli ordini solo digitali', SKU_FETCH_FAILED: 'Errore nel recupero degli SKU', SHIPPING_RATES_CALCULATION_FAILED: 'Errore nel calcolo delle tariffe di spedizione', diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts index dcf0eb34..ae23da6e 100644 --- a/packages/localizations/src/ptBr.ts +++ b/packages/localizations/src/ptBr.ts @@ -296,6 +296,8 @@ export const ptBr = { ORDER_TOTALS_UPDATE_FAILED: 'Falha ao atualizar totais do pedido', ORDER_TOTAL_CALCULATION_FAILED: 'Falha ao calcular totais do pedido', SHIPPING_METHOD_APPLICATION_FAILED: 'Falha ao aplicar método de envio', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'Métodos de envio não se aplicam a pedidos somente digitais', SKU_FETCH_FAILED: 'Falha ao buscar SKUs', SHIPPING_RATES_CALCULATION_FAILED: 'Falha ao calcular taxas de envio', SHIPPING_METHOD_NOT_FOUND: 'Método de envio não encontrado', diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts index 28e5a478..3f72740e 100644 --- a/packages/localizations/src/qaPs.ts +++ b/packages/localizations/src/qaPs.ts @@ -300,6 +300,8 @@ export const qaPs = { ORDER_TOTALS_UPDATE_FAILED: '[Fâîlëd ţö üþdâţë ördër ţöţâlš]', ORDER_TOTAL_CALCULATION_FAILED: '[Fâîlëd ţö çâlçülâţë ördër ţöţâlš]', SHIPPING_METHOD_APPLICATION_FAILED: '[Fâîlëd ţö âþþlÿ šhîþþîñg mëţhöd]', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + "[Šhîþþîñg mëţhödš döñ'ţ âþþļÿ ţö dîgîţâļ-öñļÿ ördërš]", SKU_FETCH_FAILED: '[Fâîlëd ţö fëţçh ŠKÜ îñförmâţîöñ]', SHIPPING_RATES_CALCULATION_FAILED: '[Fâîlëd ţö çâlçülâţë šhîþþîñg râţëš]', SHIPPING_METHOD_NOT_FOUND: '[Šhîþþîñg mëţhöd ñöţ föüñd îñ šÿšţëm]', diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts index 97427426..7854783b 100644 --- a/packages/localizations/src/trTr.ts +++ b/packages/localizations/src/trTr.ts @@ -294,6 +294,8 @@ export const trTr = { ORDER_TOTALS_UPDATE_FAILED: 'Sipariş toplamları güncellenemedi', ORDER_TOTAL_CALCULATION_FAILED: 'Sipariş toplamları hesaplanamadı', SHIPPING_METHOD_APPLICATION_FAILED: 'Kargo yöntemi uygulanamadı', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'Kargo yöntemleri yalnızca dijital siparişler için geçerli değildir', SKU_FETCH_FAILED: "SKU'lar alınamadı", SHIPPING_RATES_CALCULATION_FAILED: 'Kargo ücretleri hesaplanamadı', SHIPPING_METHOD_NOT_FOUND: 'Kargo yöntemi bulunamadı', diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts index b838a6cd..d829fe58 100644 --- a/packages/localizations/src/viVn.ts +++ b/packages/localizations/src/viVn.ts @@ -293,6 +293,8 @@ export const viVn = { ORDER_TOTAL_CALCULATION_FAILED: 'Không thể tính tổng đơn hàng', SHIPPING_METHOD_APPLICATION_FAILED: 'Không thể áp dụng phương thức vận chuyển', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: + 'Phương thức vận chuyển không áp dụng cho đơn hàng chỉ có sản phẩm kỹ thuật số', SKU_FETCH_FAILED: 'Không thể tải SKU', SHIPPING_RATES_CALCULATION_FAILED: 'Không thể tính phí vận chuyển', SHIPPING_METHOD_NOT_FOUND: 'Không tìm thấy phương thức vận chuyển', diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts index 8feb4056..229f9bb8 100644 --- a/packages/localizations/src/zhCn.ts +++ b/packages/localizations/src/zhCn.ts @@ -284,6 +284,7 @@ export const zhCn = { ORDER_TOTALS_UPDATE_FAILED: '更新订单总计失败', ORDER_TOTAL_CALCULATION_FAILED: '计算订单总计失败', SHIPPING_METHOD_APPLICATION_FAILED: '应用配送方式失败', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: '配送方式不适用于纯数字商品订单', SKU_FETCH_FAILED: '获取 SKU 失败', SHIPPING_RATES_CALCULATION_FAILED: '计算配送费率失败', SHIPPING_METHOD_NOT_FOUND: '配送方式未找到', diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts index dbe6bb16..d380a7a1 100644 --- a/packages/localizations/src/zhSg.ts +++ b/packages/localizations/src/zhSg.ts @@ -284,6 +284,7 @@ export const zhSg = { ORDER_TOTALS_UPDATE_FAILED: '更新订单总额失败', ORDER_TOTAL_CALCULATION_FAILED: '计算订单总额失败', SHIPPING_METHOD_APPLICATION_FAILED: '应用运送方式失败', + SHIPPING_NOT_APPLICABLE_FOR_DIGITAL_ORDER: '运送方式不适用于纯数字商品订单', SKU_FETCH_FAILED: '获取SKU失败', SHIPPING_RATES_CALCULATION_FAILED: '计算运送费率失败', SHIPPING_METHOD_NOT_FOUND: '运送方式未找到', 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..562d625f 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx @@ -11,7 +11,7 @@ import { QueryClient } from '@tanstack/react-query'; import type { RenderResult } from '@testing-library/react'; import { act, render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import React from 'react'; +import type React from 'react'; import { Checkout, type CheckoutProps } from '@/components/checkout/checkout'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; @@ -572,6 +572,10 @@ function makeDraftOrderResponse() { }; } +function isDigitalMockLineItem(lineItem: DraftOrderLineItem) { + return lineItem.type === DeliveryMethods.DIGITAL; +} + function applyShippingLines(shippingMethods: unknown) { if (!state || !Array.isArray(shippingMethods)) return; @@ -615,7 +619,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 +663,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/__tests__/checkout-validation.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx index d39f5c01..9a6edc5a 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { Checkout } from '@/components/checkout/checkout'; import { GoDaddyProvider } from '@/godaddy-provider'; +import { CheckoutType, PaymentProvider } from '@/types'; import { advanceCheckoutDebounce, buildCheckoutSession, @@ -63,6 +64,79 @@ describe('Checkout validation behaviors', () => { }); expect(getOperations('TokenizeJs.getNonce')).toHaveLength(0); }); + + it('shows billing names and phone for offline pickup even when billing address collection is enabled', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + lineItems: [{ fulfillmentMode: 'PICKUP' }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: true, + enableBillingAddressCollection: true, + enablePhoneCollection: true, + enableTaxCollection: false, + paymentMethods: { + card: { + processor: PaymentProvider.STRIPE, + checkoutTypes: [CheckoutType.STANDARD], + }, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: [CheckoutType.STANDARD], + }, + }, + }, + }); + await waitForCheckoutReady(); + + await user.click( + await screen.findByRole('button', { name: /offline payments/i }) + ); + + expect( + document.querySelector('input[name="billingFirstName"]') + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingLastName"]') + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/201.*555/)).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + expect( + document.querySelector('input[name="billingPostalCode"]') + ).not.toBeInTheDocument(); + }); + + it('shows billing names and phone when billing address collection is disabled but phone collection is enabled', async () => { + renderCheckout({ + draftOrderOverrides: { + lineItems: [{ fulfillmentMode: 'PICKUP' }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: true, + enableBillingAddressCollection: false, + enablePhoneCollection: true, + }, + }); + await waitForCheckoutReady(); + + expect( + document.querySelector('input[name="billingFirstName"]') + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingLastName"]') + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/201.*555/)).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + expect( + document.querySelector('input[name="billingPostalCode"]') + ).not.toBeInTheDocument(); + }); }); describe('Checkout notes UI', () => { 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}