diff --git a/.changeset/fresh-catalog-products.md b/.changeset/fresh-catalog-products.md new file mode 100644 index 00000000..a50d074e --- /dev/null +++ b/.changeset/fresh-catalog-products.md @@ -0,0 +1,5 @@ +--- +'@godaddy/commerce-server': patch +--- + +Load active catalog products and SKUs in merchant-defined variant order. diff --git a/.changeset/quiet-tax-totals.md b/.changeset/quiet-tax-totals.md new file mode 100644 index 00000000..4a2e5162 --- /dev/null +++ b/.changeset/quiet-tax-totals.md @@ -0,0 +1,6 @@ +--- +'@godaddy/commerce-storefront': patch +'@godaddy/commerce-server': patch +--- + +Show the draft-order subtotal and explain that shipping, taxes, and discounts are calculated at checkout. diff --git a/packages/commerce-server/README.md b/packages/commerce-server/README.md index c1741082..b80ea5ca 100644 --- a/packages/commerce-server/README.md +++ b/packages/commerce-server/README.md @@ -22,7 +22,9 @@ The default configuration reads these **server-only environment variables** on e - `GODADDY_OAUTH_CLIENT_ID` and `GODADDY_OAUTH_CLIENT_SECRET` - `GODADDY_STORE_ID` and `GODADDY_CHANNEL_ID` - `GODADDY_CURRENCY_CODE` -- Optional `GODADDY_CHECKOUT_CONFIGURATION`: JSON with boolean `enablePromotionCodes`, `enableTaxCollection`, and `enableShipping` fields. All three default to false when this variable is absent. Optional `shipping` accepts the checkout API's `originAddress` or `fulfillmentLocationId`; omit it to use store configuration. +- Optional `GODADDY_CHECKOUT_CONFIGURATION`: JSON with boolean `enablePromotionCodes`, `enableTaxCollection`, and `enableShipping` fields. All three default to false when this variable is absent or malformed so configuration discovery cannot prevent checkout creation. Optional `shipping` accepts the checkout API's `originAddress` or `fulfillmentLocationId`; omit it to use store configuration. + +See [Checkout configuration](docs/checkout-configuration.md) for the temporary build-time synchronization flow and its eventual-consistency limitation. The API origin defaults to `https://api.godaddy.com`. The package does not load files, provision merchants, or assign application attribution. Hosts own these concerns and any readiness checks or retries before invoking Commerce. diff --git a/packages/commerce-server/docs/checkout-configuration.md b/packages/commerce-server/docs/checkout-configuration.md new file mode 100644 index 00000000..8e09e268 --- /dev/null +++ b/packages/commerce-server/docs/checkout-configuration.md @@ -0,0 +1,62 @@ +# Checkout configuration + +## Goal + +Storefronts should respond to a merchant enabling or disabling shipping, taxes, or discounts without requiring the builder agent to edit application code. This document records the current design, its temporary limitation, and the intended long-term solution. + +## Decisions + +- The cart remains a draft order. We will not create a checkout session when the cart is created or maintain a lazy session while the shopper edits it. +- The cart does not calculate or estimate shipping, taxes, or discounts. It displays **Subtotal** and one message: “Shipping, taxes, and discounts are calculated at checkout.” The message has the stable `commerce-cart-checkout-adjustments-note` class so an application can hide it easily. +- A checkout session is created only when the shopper selects **Checkout**. The Checkout API and the enabled commerce providers perform the actual calculations. +- Checkout capability flags are server-only. They must not be exposed by `/api/commerce/config` or supplied by browser code. +- We will defer live capability discovery in this library because the Checkout API is expected to discover enabled providers through App Registry. Until that work ships, configuration can be refreshed during an initial build or a later rebuild/redeploy. + +This keeps the cart simple, avoids a second source of pricing logic, and lets shipping rates, tax rules, and discounts remain authoritative in their respective services. + +## Source of truth + +App Registry is the source of truth for whether the store has a provider enabled for each capability: + +| Capability | App Registry action | +| --- | --- | +| Shipping | `commerce.shipping-rates.calculate` | +| Taxes | `commerce.taxes.calculate` | +| Discounts | `commerce.price-adjustment.apply` | + +App Registry indicates that a capable GPA is enabled; the shipping, tax, and discount services still own their settings, rules, and calculations. The general Settings API is not a consolidated source for these three enablement states. + +Commerce Admin already exposes this lookup to builders through the `commerce_checkout_configuration_get` MCP tool. Given a `storeId` and `channelId`, it validates their binding, queries enabled App Registry actions, and returns checkout flags such as `enableShipping`, `enableTaxCollection`, and `enablePromotionCodes`, plus shipping-origin readiness. + +## Temporary build-time flow + +Until Checkout API performs App Registry discovery itself: + +1. During every initial build and rebuild/redeploy, the builder calls `commerce_checkout_configuration_get`. +2. The deployment writes the three returned feature flags to the server-only `GODADDY_CHECKOUT_CONFIGURATION` value; it does not copy the full MCP response or generate conditional application code. +3. When Checkout is selected, `commerce-server` reads that value and explicitly configures the new checkout session. +4. The enabled providers calculate live rates and adjustments during checkout. + +For example: + +```json +{ + "enablePromotionCodes": true, + "enableTaxCollection": true, + "enableShipping": true +} +``` + +Restoring the server reader alone does not discover merchant settings: the builder/deployment integration must populate this value. Shipping-origin address details do not need to be copied into application code; hosted checkout can use store configuration. + +The accepted stopgap is eventually consistent: a provider enabled or disabled after deployment is reflected on the next rebuild, not immediately. This is acceptable only while the Checkout API change is pending. + +If the MCP lookup fails, the deployment does not receive the expected flags, or the server value is malformed, checkout falls back to all optional capabilities disabled. In particular, it sends `enableShipping: false` rather than preventing checkout creation. Builders should surface the lookup problem during the build, but it must not make the deployed checkout unusable. + +## Important compatibility detail + +The current checkout request builder applies explicit defaults of `false` for shipping, shipping-address collection, and tax collection. Removing `GODADDY_CHECKOUT_CONFIGURATION` without changing that behavior disables those features and is a regression. Also, omitting fields is not a complete substitute: Checkout API currently defaults shipping to enabled, but does not similarly enable address collection, taxes, or promotion codes. + +## Long-term flow + +Checkout API should query App Registry when a session is created and automatically enable shipping, taxes, and discounts for the store’s active GPAs. Once that is available and verified, this repository can remove the static checkout configuration and its build-time synchronization. The cart and draft-order lifecycle do not need to change. diff --git a/packages/commerce-server/src/config.test.ts b/packages/commerce-server/src/config.test.ts index 17b5db12..a2c6a920 100644 --- a/packages/commerce-server/src/config.test.ts +++ b/packages/commerce-server/src/config.test.ts @@ -97,14 +97,20 @@ describe('Commerce runtime configuration', () => { }); }); - it.each(['{bad json', 'null', '{"enableShipping":true}'])( - 'rejects malformed checkout configuration: %s', - (raw): void => { - expect(() => - createRuntimeCommerceConfiguration({ - environment: { ...environment(), GODADDY_CHECKOUT_CONFIGURATION: raw }, - }).readCheckout(), - ).toThrow('Commerce config: GODADDY_CHECKOUT_CONFIGURATION'); - }, - ); + it.each([ + '{bad json', + 'null', + '{"enableShipping":true}', + '{"enablePromotionCodes":false,"enableTaxCollection":false,"enableShipping":true,"shipping":{"originAddressConfigured":true}}', + ])('defaults malformed checkout configuration to disabled capabilities: %s', (raw): void => { + expect( + createRuntimeCommerceConfiguration({ + environment: { ...environment(), GODADDY_CHECKOUT_CONFIGURATION: raw }, + }).readCheckout(), + ).toEqual({ + enablePromotionCodes: false, + enableTaxCollection: false, + enableShipping: false, + }); + }); }); diff --git a/packages/commerce-server/src/configuration-integration.test.ts b/packages/commerce-server/src/configuration-integration.test.ts index ff9ec77e..14d2d2d5 100644 --- a/packages/commerce-server/src/configuration-integration.test.ts +++ b/packages/commerce-server/src/configuration-integration.test.ts @@ -91,7 +91,10 @@ it.each([undefined, 'https://api.example.com', 'https://api.example.com:8443'])( const base = `http://127.0.0.1:${address.port}/api/commerce`; const configResponse = await clientFetch(`${base}/config`); const publicConfig = await configResponse.json(); - expect(publicConfig).toEqual({ cartScope: expect.any(String), currencyCode: 'USD' }); + expect(publicConfig).toEqual({ + cartScope: expect.any(String), + currencyCode: 'USD', + }); const headers = { 'Content-Type': 'application/json', 'X-Commerce-Scope': publicConfig.cartScope }; expect((await clientFetch(`${base}/products`, { headers })).status).toBe(200); expect( diff --git a/packages/commerce-server/src/create-checkout-session.test.ts b/packages/commerce-server/src/create-checkout-session.test.ts index 82cb754a..1e615dd7 100644 --- a/packages/commerce-server/src/create-checkout-session.test.ts +++ b/packages/commerce-server/src/create-checkout-session.test.ts @@ -101,6 +101,16 @@ describe('createCheckoutSession', () => { }, ); + it('disables optional checkout capabilities when configuration falls back to defaults', async (): Promise => { + await createCheckoutSession(cart, configuration); + expect(mockGqlRequest.mock.calls[0]?.[0].variables.input).toMatchObject({ + enablePromotionCodes: false, + enableTaxCollection: false, + enableShipping: false, + enableShippingAddressCollection: false, + }); + }); + it.each(flows)('uses only host-owned attribution for %s checkout', async (_name, params): Promise => { config = { ...config, sourceApp: 'merchant-site', owner: 'merchant-orders' }; mockGqlRequest.mockResolvedValue(response({ sourceApp: 'merchant-site' })); @@ -143,13 +153,6 @@ describe('createCheckoutSession', () => { }, ); - it.each(flows)('rejects missing payment methods for %s checkout', async (_name, params): Promise => { - mockGqlRequest.mockResolvedValue(response({ paymentMethods: null })); - await expect(createCheckoutSession(params, configuration)).rejects.toThrow( - 'Checkout session did not configure payment methods.', - ); - }); - it.each([flows[1], flows[2]])( 'uses the store shipping configuration for %s checkout', async (_name, params): Promise => { @@ -234,6 +237,13 @@ describe('createCheckoutSession', () => { ); }); + it.each(flows)('rejects missing payment methods for %s checkout', async (_name, params): Promise => { + mockGqlRequest.mockResolvedValue(response({ paymentMethods: null })); + await expect(createCheckoutSession(params, configuration)).rejects.toThrow( + 'Checkout session did not configure payment methods.', + ); + }); + it('uses the configured currency for non-catalog pricing', async (): Promise => { config.currencyCode = 'GBP'; await createCheckoutSession( diff --git a/packages/commerce-server/src/lib/commerce/cart-scope.ts b/packages/commerce-server/src/lib/commerce/cart-scope.ts index 8854944a..46c5382a 100644 --- a/packages/commerce-server/src/lib/commerce/cart-scope.ts +++ b/packages/commerce-server/src/lib/commerce/cart-scope.ts @@ -3,12 +3,12 @@ import { createHash } from 'node:crypto'; import type { Request, Response } from 'express'; import type { CommerceConfig } from './config'; -type CartBinding = Pick; +type CartBinding = Pick; /** Public cache/storage scope, not an authorization credential. */ export function getCommerceCartScope(config: CartBinding): string { return createHash('sha256') - .update(JSON.stringify([config.apiBaseUrl, config.storeId, config.channelId])) + .update(JSON.stringify([config.apiBaseUrl, config.storeId, config.channelId, config.currencyCode])) .digest('hex') .slice(0, 32); } diff --git a/packages/commerce-server/src/lib/commerce/checkout-config.ts b/packages/commerce-server/src/lib/commerce/checkout-config.ts index 7cae4047..6568a42d 100644 --- a/packages/commerce-server/src/lib/commerce/checkout-config.ts +++ b/packages/commerce-server/src/lib/commerce/checkout-config.ts @@ -23,28 +23,34 @@ export function parseCommerceCheckoutConfiguration(raw: string | undefined): Com let value: unknown; try { value = JSON.parse(raw); - } catch (error) { - throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION must be valid JSON.', { - cause: error, - }); + } catch { + return DEFAULT_CHECKOUT_CONFIGURATION; } if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION must be a JSON object.'); + return DEFAULT_CHECKOUT_CONFIGURATION; } const candidate = value as Record; for (const key of ['enablePromotionCodes', 'enableTaxCollection', 'enableShipping'] as const) { if (typeof candidate[key] !== 'boolean') { - throw new Error(`Commerce config: GODADDY_CHECKOUT_CONFIGURATION.${key} must be boolean.`); + return DEFAULT_CHECKOUT_CONFIGURATION; } } - const shipping = candidate.shipping; - if ( - shipping !== undefined && - (shipping === null || typeof shipping !== 'object' || Array.isArray(shipping)) - ) { - throw new Error('Commerce config: GODADDY_CHECKOUT_CONFIGURATION.shipping must be an object.'); + const shipping = candidate.shipping as Record | undefined; + if (shipping !== undefined) { + if (shipping === null || typeof shipping !== 'object' || Array.isArray(shipping)) { + return DEFAULT_CHECKOUT_CONFIGURATION; + } + const hasOriginAddress = + shipping.originAddress !== null && + typeof shipping.originAddress === 'object' && + !Array.isArray(shipping.originAddress); + const hasFulfillmentLocationId = + typeof shipping.fulfillmentLocationId === 'string' && shipping.fulfillmentLocationId.trim() !== ''; + if (hasOriginAddress === hasFulfillmentLocationId) { + return DEFAULT_CHECKOUT_CONFIGURATION; + } } return { diff --git a/packages/commerce-server/src/router.test.ts b/packages/commerce-server/src/router.test.ts index 3bb8bd54..657b7d05 100644 --- a/packages/commerce-server/src/router.test.ts +++ b/packages/commerce-server/src/router.test.ts @@ -3,6 +3,7 @@ import type { Request, Response } from 'express'; import express from 'express'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getCommerceCartScope } from './lib/commerce/cart-scope'; +import type { CommerceConfiguration } from './lib/commerce/config'; import { createCheckoutSession } from './lib/commerce/create-checkout-session'; import { GraphQLErrorWithCodes, gqlRequest } from './lib/commerce/gql'; import { getCartOrderQuery } from './lib/commerce/order-subgraph'; @@ -36,7 +37,7 @@ const binding = { clientId: 'client-1', clientSecret: 'server-only-secret', }; -const configuration = { +const configuration: CommerceConfiguration = { read: (): typeof binding => binding, readCheckout: (): { enablePromotionCodes: false; enableTaxCollection: false; enableShipping: false } => ({ enablePromotionCodes: false, @@ -65,6 +66,10 @@ describe('Commerce scoped routes', () => { expect(getCartOrderQuery).not.toMatch(/\bstatuses\s*\{/); }); + it('uses currency as part of the persisted cart binding', (): void => { + expect(getCommerceCartScope(binding)).not.toBe(getCommerceCartScope({ ...binding, currencyCode: 'GBP' })); + }); + it.each([readCart, addItem, updateItem, deleteItem, applyDiscount, readProduct, readSku])( 'rejects array route IDs before an upstream request', async (handler): Promise => { @@ -100,9 +105,22 @@ describe('Commerce scoped routes', () => { expect(query).toContain('prices(first: 10)'); expect(query).toContain('inventoryCounts'); expect(query).toContain('pageInfo { hasNextPage }'); + expect(query).toContain('attributes(first: 50, orderBy: { position: ASC })'); + expect(query).toContain('values(first: 50, orderBy: { position: ASC })'); + expect(query).toContain('status: { eq: "ACTIVE" }'); expect(res.json).toHaveBeenCalledWith({ skuGroup: { id: 'product' } }); }); + it('loads only active catalog products and active card SKUs', async (): Promise => { + const res: ReturnType = response(); + vi.mocked(gqlRequest).mockResolvedValueOnce({ skuGroups: { edges: [] } }); + await readProducts({ query: {} } as unknown as Request, res as unknown as Response); + const query: string = vi.mocked(gqlRequest).mock.calls[0]?.[0].query ?? ''; + expect(query).toContain('status: { eq: "ACTIVE" }'); + expect(query).toContain('skus(first: 2, status: { eq: "ACTIVE" })'); + expect(query).toContain('priceRange(status: { eq: "ACTIVE" })'); + }); + it.each([readProducts, readProduct, readSku])( 'keeps catalog queries within the upstream depth limit of 10', async (handler: typeof readProducts): Promise => { diff --git a/packages/commerce-server/src/server/api/commerce/config/GET.ts b/packages/commerce-server/src/server/api/commerce/config/GET.ts index 7deb364a..3169634f 100644 --- a/packages/commerce-server/src/server/api/commerce/config/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/config/GET.ts @@ -1,17 +1,21 @@ /** * GET /api/commerce/config - * Public { cartScope, currencyCode } for the shared CommerceProvider. + * Public binding configuration for the shared CommerceProvider. * Store/channel IDs and credentials stay server-side. Do not cache across bindings. */ import type { Request, Response } from 'express'; import { getCommerceCartScope } from '@/lib/commerce/cart-scope'; -import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { type CommerceConfig, commerceConfigurationForResponse } from '@/lib/commerce/config'; export default async function handler(_req: Request, res: Response): Promise { res.setHeader('Cache-Control', 'no-store'); try { - const config: CommerceConfig = readCommerceConfigForResponse(res); - res.json({ cartScope: getCommerceCartScope(config), currencyCode: config.currencyCode }); + const configuration = commerceConfigurationForResponse(res); + const config: CommerceConfig = configuration.read(); + res.json({ + cartScope: getCommerceCartScope(config), + currencyCode: config.currencyCode, + }); } catch (cause: unknown) { res.status(503).json({ error: 'Commerce configuration is unavailable. Complete the store connection before continuing.', diff --git a/packages/commerce-server/src/server/api/commerce/products/GET.ts b/packages/commerce-server/src/server/api/commerce/products/GET.ts index aea3f87d..e2ccfb7b 100644 --- a/packages/commerce-server/src/server/api/commerce/products/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/products/GET.ts @@ -32,7 +32,14 @@ import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; // Nested SKU price money fields would exceed the catalog API's depth limit of 10. const skuGroupsQuery = ` query SkuGroups($first: Int, $after: String, $id: SKUGroupIdsFilter, $listId: ListIdFilter, $label: LabelFilter) { - skuGroups(first: $first, after: $after, id: $id, listId: $listId, label: $label) { + skuGroups( + first: $first + after: $after + id: $id + listId: $listId + label: $label + status: { eq: "ACTIVE" } + ) { edges { cursor node { @@ -42,11 +49,11 @@ const skuGroupsQuery = ` description htmlDescription type - priceRange { + priceRange(status: { eq: "ACTIVE" }) { min max } - compareAtPriceRange { + compareAtPriceRange(status: { eq: "ACTIVE" }) { min max } @@ -58,7 +65,7 @@ const skuGroupsQuery = ` } } } - attributes { + attributes(first: 50, orderBy: { position: ASC }) { edges { node { id @@ -66,7 +73,7 @@ const skuGroupsQuery = ` label description htmlDescription - values(first: 50) { + values(first: 50, orderBy: { position: ASC }) { edges { node { id @@ -78,7 +85,7 @@ const skuGroupsQuery = ` } } } - skus(first: 2) { + skus(first: 2, status: { eq: "ACTIVE" }) { pageInfo { hasNextPage } totalCount edges { diff --git a/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts b/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts index 1fb13b99..5e96325a 100644 --- a/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts @@ -34,11 +34,11 @@ const skuGroupQuery = ` description htmlDescription type - priceRange { + priceRange(status: { eq: "ACTIVE" }) { min max } - compareAtPriceRange { + compareAtPriceRange(status: { eq: "ACTIVE" }) { min max } @@ -50,7 +50,7 @@ const skuGroupQuery = ` } } } - attributes { + attributes(first: 50, orderBy: { position: ASC }) { edges { node { id @@ -58,7 +58,7 @@ const skuGroupQuery = ` label description htmlDescription - values(first: 50) { + values(first: 50, orderBy: { position: ASC }) { edges { node { id @@ -70,7 +70,7 @@ const skuGroupQuery = ` } } } - skus(attributeValues: { has: $attributeValues }, first: $first) { + skus(attributeValues: { has: $attributeValues }, first: $first, status: { eq: "ACTIVE" }) { pageInfo { hasNextPage } totalCount edges { diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 1156dfe9..475b8b99 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -58,7 +58,7 @@ Use the providers your application already has; do not create another router or Provide root-relative paths without a trailing slash. Enable `checkoutSuccessPath` only after your server supports checkout and validates merchant readiness. Mount a corresponding return page. A redirect back from checkout is **not proof of payment**; that page must obtain authoritative payment status from your server. This package does not provide a payment receipt page or merchant onboarding. -`GET /api/commerce/config` supplies the currency and opaque cart scope. The scope must change when the store/channel binding changes. Applications do not pass store IDs or credentials into the browser package. One storefront binding is supported per page and query client. +`GET /api/commerce/config` supplies the currency and opaque cart scope. The scope must change when the store, channel, or currency binding changes. The draft-order cart displays its subtotal and explains that shipping, taxes, and discounts are calculated at checkout. The explanation has the stable `commerce-cart-checkout-adjustments-note` class so applications can hide it when needed. The cart does not create a checkout session or calculate adjustments. Applications do not pass store IDs or credentials into the browser package. One storefront binding is supported per page and query client. A connection failure leaves the surrounding application and its state mounted. Catalog and product surfaces show the connection error and retry action. Custom integrations can render `CommerceStatus` or inspect `useCommerce().connection`. diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index 9b73a489..5675eead 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -11,13 +11,13 @@ Server implementations can use GoDaddy Commerce APIs or their existing integrati - Read requests use `cache: no-store`, cancellation, and a 15-second timeout. The initial cart creation/add request is also a write and is never retried automatically. - Return a non-2xx response with `{ "error": "A useful customer-facing message" }` for failure. HTML error pages are also handled as failures. - Only cart reads with HTTP 404 or 410 clear an expired saved cart. Network errors, 401/403/409/5xx, and other failures preserve the ID and block writes until hydration succeeds. -- Resolve prices, taxes, discounts and availability on the server. Browser SKU names and quantities are input, not pricing authority. Protect mutations against CSRF as appropriate for the host application's authentication. +- Resolve prices and availability on the server. The cart does not calculate shipping, taxes, or discounts; it explains that they are calculated at checkout. Browser SKU names and quantities are input, not pricing authority. Protect mutations against CSRF as appropriate for the host application's authentication. ## Endpoints | Method and path | Input | Successful JSON response | | --- | --- | --- | -| `GET /config` | None | `{ cartScope: string, currencyCode: string }` | +| `GET /config` | None | `{ cartScope, currencyCode }` | | `GET /products?first=6&after=` | Optional opaque cursor | `{ skuGroups: Connection }` | | `GET /products/:id` | URI-encoded product ID | `{ skuGroup: SKUGroup \| null }` | | `GET /products/:id?attributeValues=blue&attributeValues=large` | Repeated selected attribute **names**, not IDs | `{ skuGroup: SKUGroup \| null }` with matching SKU connection | @@ -33,7 +33,7 @@ Server implementations can use GoDaddy Commerce APIs or their existing integrati ## Configuration -`cartScope` is a nonempty opaque identifier for the effective store/channel binding. It is not a secret. Rotate it when the binding changes so a saved cart cannot cross stores. `currencyCode` is a three-letter uppercase ISO 4217 code, for example `USD`. Money integers use that currency's smallest unit: USD 1234 is $12.34; JPY 1234 is ¥1,234. +`cartScope` is a nonempty opaque identifier for the effective store/channel/currency binding. It is not a secret. Rotate it when that binding changes so a saved cart cannot cross stores or currencies. `currencyCode` is a three-letter uppercase ISO 4217 code, for example `USD`. Money integers use that currency's smallest unit: USD 1234 is $12.34; JPY 1234 is ¥1,234. The cart shows the draft-order subtotal and the message “Shipping, taxes, and discounts are calculated at checkout.” The message has the stable `commerce-cart-checkout-adjustments-note` class so a host can hide it without changing the component. The browser rechecks configuration on window focus when stale. Return current server configuration rather than a browser-selected store. Persisted IDs use the package-specific key documented in the README; migration from another application's storage keys belongs to that application's integration. diff --git a/packages/commerce-storefront/src/cart.tsx b/packages/commerce-storefront/src/cart.tsx index 592557de..3b6fa554 100644 --- a/packages/commerce-storefront/src/cart.tsx +++ b/packages/commerce-storefront/src/cart.tsx @@ -263,31 +263,16 @@ export function CartDrawer(): ReactElement { {items.length > 0 && (
- {( - [ - ['Subtotal', cart?.totals?.subTotal, summary.subtotal], - ['Shipping', cart?.totals?.shippingTotal, summary.shipping], - ['Tax', cart?.totals?.taxTotal, summary.taxes], - ] as const - ).map( - ([label, amount, value]) => - typeof amount?.value === 'number' && ( -
-
{label}
-
{money(value, amount.currencyCode ?? currency)}
-
- ), + {typeof cart?.totals?.subTotal?.value === 'number' && ( +
+
Subtotal
+
{money(summary.subtotal, cart.totals.subTotal.currencyCode ?? currency)}
+
)} -
-
Total
-
- {typeof cart?.totals?.total?.value === 'number' - ? money(summary.total, currency) - : 'Unavailable'} -
-
-

Shipping and taxes may change at checkout.

+

+ Shipping, taxes, and discounts are calculated at checkout. +

{config.checkoutSuccessPath ? (