diff --git a/apps/storefront/src/App.tsx b/apps/storefront/src/App.tsx index 92b9a60..a4756f5 100644 --- a/apps/storefront/src/App.tsx +++ b/apps/storefront/src/App.tsx @@ -24,6 +24,7 @@ import { useAuthHeartbeat, useAuthenticatedAccountId } from "./lib/auth"; import { useSyncCartToBackend } from "./lib/backendCart"; import { readingListRemote, wishlistRemote } from "./lib/savedLists"; import type { StorefrontRouteKey } from "./lib/storefrontPaths"; +import { matchesStorefrontFallbackPath } from "./lib/routePathMatching"; import { useResolvedStorefrontLanguageRoute, useResolvedStorefrontPath } from "./lib/storefrontPaths"; import { applyLayoutConfiguration, useLayoutPreferencesFromBackendConfig } from "./lib/layoutPreferencesSync"; import { CreatorContentProvider } from "./state/creatorContent"; @@ -485,7 +486,7 @@ function StorefrontRedirectRoute({ useEffect(() => { if (isLoading || resolvedPath === currentPath) return; - if (currentPath === normalizeBrowserPath(fallback)) { + if (matchesStorefrontFallbackPath(currentPath, fallback, routeLanguage)) { navigate(`${path}${location.search}${location.hash}`, { replace: true }); } }, [currentPath, fallback, isLoading, location.hash, location.search, navigate, path, resolvedPath]); diff --git a/apps/storefront/src/lib/checkout.ts b/apps/storefront/src/lib/checkout.ts index e246c8d..14fa94b 100644 --- a/apps/storefront/src/lib/checkout.ts +++ b/apps/storefront/src/lib/checkout.ts @@ -1,13 +1,12 @@ /** Checkout utilities — shipping, taxes, coupons, and related calculations. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { CartLineItem } from "@funky/ui"; import { syncCartToBackend } from "./backendCart"; import { isBackendConfigured } from "@funky/sdk"; import { applyCoupon, calculateTaxes, - getCart, getShippingMethods, removeCoupon, selectShippingMethod, @@ -58,69 +57,117 @@ export function useCheckoutCart( cart: StoreApiCart | null; loading: boolean; error: string | null; - }>({ cart: null, loading: false, error: null }); + syncedCartRevision: string | null; + }>({ cart: null, loading: false, error: null, syncedCartRevision: null }); const addressKey = [billingAddress, shippingAddress] .map((address) => address ? [ - address.first_name, - address.last_name, address.address_1, address.address_2 ?? "", address.city, address.state ?? "", address.postcode, address.country, - address.email ?? "", - address.phone ?? "", ].join("|") : "") - .concat(cartRevision) .join("::"); + const latestAddressesRef = useRef({ billingAddress, shippingAddress }); + latestAddressesRef.current = { billingAddress, shippingAddress }; + const lastRequestedAddressKeyRef = useRef(addressKey); useEffect(() => { if (!isBackendConfigured || !billingAddress || !shippingAddress) { - setState({ cart: null, loading: false, error: null }); + setState({ cart: null, loading: false, error: null, syncedCartRevision: null }); return; } let cancelled = false; - setState((previous) => ({ ...previous, loading: true, error: null })); + lastRequestedAddressKeyRef.current = addressKey; + setState((previous) => ({ + ...previous, + loading: true, + error: null, + syncedCartRevision: null, + })); void syncCartToBackend(frontendCart, { - force: true, verifyForCheckout: true, ignoreSuspension: true, }).then(async (syncResult) => { if (cancelled) return; if (!syncResult.ok) { - setState({ cart: null, loading: false, error: syncResult.error }); + setState({ + cart: null, + loading: false, + error: syncResult.error, + syncedCartRevision: null, + }); + return; + } + const latestAddresses = latestAddressesRef.current; + if (!latestAddresses.billingAddress || !latestAddresses.shippingAddress) { + setState({ cart: null, loading: false, error: null, syncedCartRevision: null }); return; } - const result = await updateCartCustomer(billingAddress, shippingAddress); + const result = await updateCartCustomer( + latestAddresses.billingAddress, + latestAddresses.shippingAddress, + ); if (cancelled) return; if (!result.ok) { - setState({ cart: null, loading: false, error: result.error }); + setState({ + cart: null, + loading: false, + error: result.error, + syncedCartRevision: null, + }); return; } - const refreshed = await getCart(); - if (cancelled) return; - setState(refreshed.ok - ? { cart: refreshed.data, loading: false, error: null } - : { cart: result.data, loading: false, error: refreshed.error }); + setState({ + cart: result.data, + loading: false, + error: null, + syncedCartRevision: cartRevision, + }); }); return () => { cancelled = true; }; - }, [addressKey]); + }, [cartRevision]); + + useEffect(() => { + if (!isBackendConfigured || !billingAddress || !shippingAddress) { + lastRequestedAddressKeyRef.current = addressKey; + return; + } + if (lastRequestedAddressKeyRef.current === addressKey) return; + + lastRequestedAddressKeyRef.current = addressKey; + let cancelled = false; + setState((previous) => ({ ...previous, loading: true, error: null })); + const timeoutId = window.setTimeout(() => { + void updateCartCustomer(billingAddress, shippingAddress).then((result) => { + if (cancelled) return; + setState((previous) => result.ok + ? { ...previous, cart: result.data, loading: false, error: null } + : { ...previous, loading: false, error: result.error }); + }); + }, 300); + + return () => { + cancelled = true; + window.clearTimeout(timeoutId); + }; + }, [addressKey, cartRevision]); const adoptCart = useCallback((cart: StoreApiCart) => { - setState({ cart, loading: false, error: null }); + setState((previous) => ({ ...previous, cart, loading: false, error: null })); }, []); const selectMethod = useCallback(async (packageId: number, rateId: string) => { setState((previous) => ({ ...previous, loading: true, error: null })); const result = await selectShippingMethod({ package_id: packageId, rate_id: rateId }); if (result.ok) { - setState({ cart: result.data, loading: false, error: null }); + setState((previous) => ({ ...previous, cart: result.data, loading: false, error: null })); } else { setState((previous) => ({ ...previous, loading: false, error: result.error })); } diff --git a/apps/storefront/src/lib/checkoutContext.test.ts b/apps/storefront/src/lib/checkoutContext.test.ts index a18ed0c..c0430eb 100644 --- a/apps/storefront/src/lib/checkoutContext.test.ts +++ b/apps/storefront/src/lib/checkoutContext.test.ts @@ -34,6 +34,7 @@ test("builds the Store API checkout language and attribution bridge", () => { referrer: "https://example.com/campaign", user_agent: "Storefront test browser", session_start_time: "2026-08-06T01:00:00.000Z", + digital_order: false, }, }, ); @@ -99,7 +100,7 @@ test("checkout payload preserves language, order notes, and a different shipping assert.equal(payload.customer_password, "correct-horse-battery-staple"); }); -test("digital checkout supplies required Store API and Stripe address placeholders", () => { +test("digital checkout supplies a country-valid Store API and Stripe fallback address", () => { const billing = { firstName: "Ada", lastName: "Lovelace", @@ -121,17 +122,46 @@ test("digital checkout supplies required Store API and Stripe address placeholde ); const stripeBilling = toStripeBillingDetails(paymentBilling); - assert.equal(payload.billing_address.address_1, "Digital delivery"); - assert.equal(payload.billing_address.city, "Digital order"); - assert.equal(payload.billing_address.postcode, "00000"); + assert.equal(payload.billing_address.address_1, "Dostawa cyfrowa 1"); + assert.equal(payload.billing_address.city, "Warszawa"); + assert.equal(payload.billing_address.state, "MZ"); + assert.equal(payload.billing_address.postcode, "00-001"); assert.equal(payload.billing_address.country, "PL"); assert.deepEqual(payload.shipping_address, payload.billing_address); - assert.equal(stripePaymentData.get("billing_address_1"), "Digital delivery"); - assert.equal(stripePaymentData.get("billing_city"), "Digital order"); - assert.equal(stripePaymentData.get("billing_postcode"), "00000"); - assert.equal(stripeBilling.address.line1, "Digital delivery"); - assert.equal(stripeBilling.address.city, "Digital order"); - assert.equal(stripeBilling.address.postal_code, "00000"); + assert.equal(payload.extensions?.[CHECKOUT_CONTEXT_NAMESPACE].digital_order, true); + assert.equal(stripePaymentData.get("billing_address_1"), "Dostawa cyfrowa 1"); + assert.equal(stripePaymentData.get("billing_city"), "Warszawa"); + assert.equal(stripePaymentData.get("billing_state"), "MZ"); + assert.equal(stripePaymentData.get("billing_postcode"), "00-001"); + assert.equal(stripeBilling.address.line1, "Dostawa cyfrowa 1"); + assert.equal(stripeBilling.address.city, "Warszawa"); + assert.equal(stripeBilling.address.state, "MZ"); + assert.equal(stripeBilling.address.postal_code, "00-001"); +}); + +test("digital checkout uses valid state and postcode formats for supported countries", () => { + const countries = [ + { countryCode: "DE", state: "DE-BE", postcode: "10115" }, + { countryCode: "FR", state: "75", postcode: "75001" }, + { countryCode: "GB", state: "London", postcode: "SW1A 1AA" }, + { countryCode: "NL", state: "NH", postcode: "1011 AA" }, + { countryCode: "US", state: "CA", postcode: "94105" }, + ]; + + for (const country of countries) { + const address = withDigitalCheckoutAddress({ + firstName: "Ada", + lastName: "Lovelace", + addressLine1: "", + city: "", + postcode: "", + countryCode: country.countryCode, + email: "ada@example.com", + phone: "+48 123 456 789", + }); + assert.equal(address.state, country.state); + assert.equal(address.postcode, country.postcode); + } }); test("physical checkout does not invent missing address fields", () => { diff --git a/apps/storefront/src/lib/checkoutContext.ts b/apps/storefront/src/lib/checkoutContext.ts index 2de0abc..195a23f 100644 --- a/apps/storefront/src/lib/checkoutContext.ts +++ b/apps/storefront/src/lib/checkoutContext.ts @@ -15,6 +15,7 @@ export type CheckoutContextInput = { referrer?: string; userAgent?: string; sessionStartTime?: string; + digitalOrder?: boolean; }; function trimmed(value: string | undefined, maxLength: number): string { @@ -47,6 +48,7 @@ export function buildCheckoutExtensions( referrer: trimmed(context.referrer, 500), user_agent: trimmed(context.userAgent, 500), session_start_time: trimmed(context.sessionStartTime, 40), + digital_order: context.digitalOrder === true, }, }; } @@ -67,29 +69,50 @@ function toStoreApiAddress(details: CheckoutBillingDetails): StoreApiAddress { }; } -const DIGITAL_ADDRESS_FALLBACK = { - addressLine1: "Digital delivery", - city: "Digital order", - postcode: "00000", -} as const; +const DIGITAL_ADDRESS_FALLBACKS: Record = { + DE: { addressLine1: "Digital delivery 1", city: "Berlin", state: "DE-BE", postcode: "10115" }, + FR: { addressLine1: "1 Livraison numerique", city: "Paris", state: "75", postcode: "75001" }, + GB: { addressLine1: "1 Digital Delivery", city: "London", state: "London", postcode: "SW1A 1AA" }, + NL: { addressLine1: "Digital delivery 1", city: "Amsterdam", state: "NH", postcode: "1011 AA" }, + PL: { addressLine1: "Dostawa cyfrowa 1", city: "Warszawa", state: "MZ", postcode: "00-001" }, + US: { addressLine1: "1 Digital Delivery", city: "San Francisco", state: "CA", postcode: "94105" }, +}; + +function digitalAddressFallback(countryCode: string) { + return DIGITAL_ADDRESS_FALLBACKS[countryCode.trim().toUpperCase()] ?? { + addressLine1: "Digital delivery 1", + city: "Digital order", + state: "Digital order", + postcode: "00000", + }; +} export function withDigitalCheckoutAddress( details: CheckoutBillingDetails, ): CheckoutBillingDetails { + const fallback = digitalAddressFallback(details.countryCode); return { ...details, - addressLine1: details.addressLine1.trim() || DIGITAL_ADDRESS_FALLBACK.addressLine1, - city: details.city.trim() || DIGITAL_ADDRESS_FALLBACK.city, - postcode: details.postcode.trim() || DIGITAL_ADDRESS_FALLBACK.postcode, + addressLine1: details.addressLine1.trim() || fallback.addressLine1, + city: details.city.trim() || fallback.city, + state: details.state?.trim() || fallback.state, + postcode: details.postcode.trim() || fallback.postcode, }; } export function withDigitalStoreApiAddress(address: StoreApiAddress): StoreApiAddress { + const fallback = digitalAddressFallback(address.country); return { ...address, - address_1: address.address_1.trim() || DIGITAL_ADDRESS_FALLBACK.addressLine1, - city: address.city.trim() || DIGITAL_ADDRESS_FALLBACK.city, - postcode: address.postcode.trim() || DIGITAL_ADDRESS_FALLBACK.postcode, + address_1: address.address_1.trim() || fallback.addressLine1, + city: address.city.trim() || fallback.city, + state: address.state?.trim() || fallback.state, + postcode: address.postcode.trim() || fallback.postcode, }; } @@ -124,6 +147,7 @@ export function buildStoreCheckoutPayload( referrer: typeof document !== "undefined" ? document.referrer : undefined, userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined, sessionStartTime: new Date().toISOString(), + digitalOrder: options?.digitalOrder, }), payment_data: stripePaymentData ?? diff --git a/apps/storefront/src/lib/recentOrders.test.ts b/apps/storefront/src/lib/recentOrders.test.ts index c7e1650..3d24f17 100644 --- a/apps/storefront/src/lib/recentOrders.test.ts +++ b/apps/storefront/src/lib/recentOrders.test.ts @@ -88,6 +88,10 @@ test("recent orders preserve the visible order and interval across reload-style const notifier = document.getElementById("storefront-recent-orders"); assert.ok(notifier); + assert.equal(notifier.tagName, "DIV"); + assert.equal(notifier.getAttribute("role"), "status"); + assert.equal(notifier.getAttribute("aria-live"), "polite"); + assert.equal(notifier.getAttribute("aria-atomic"), "true"); assert.match(notifier.textContent || "", /Anna bought 2 × Gallery plugin/); assert.equal(notifier.dataset.chatbotOffset, "false"); const productLink = notifier.querySelector(".storefront-recent-orders__product"); diff --git a/apps/storefront/src/lib/recentOrders.ts b/apps/storefront/src/lib/recentOrders.ts index e78b9be..a011731 100644 --- a/apps/storefront/src/lib/recentOrders.ts +++ b/apps/storefront/src/lib/recentOrders.ts @@ -196,7 +196,7 @@ function syncChatbotOffset(element: HTMLElement) { function createNotifierElement() { document.getElementById("storefront-recent-orders")?.remove(); - const element = document.createElement("aside"); + const element = document.createElement("div"); element.id = "storefront-recent-orders"; element.className = "storefront-recent-orders"; element.setAttribute("role", "status"); diff --git a/apps/storefront/src/lib/routePathMatching.test.ts b/apps/storefront/src/lib/routePathMatching.test.ts new file mode 100644 index 0000000..a93cafe --- /dev/null +++ b/apps/storefront/src/lib/routePathMatching.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { matchesStorefrontFallbackPath } from "./routePathMatching.ts"; + +test("matches localized fallback routes regardless of trailing slash", () => { + assert.equal(matchesStorefrontFallbackPath("/order-success/", "/order-success"), true); + assert.equal(matchesStorefrontFallbackPath("/pl/order-success", "/order-success", "pl"), true); + assert.equal(matchesStorefrontFallbackPath("/pl/order-success/", "/order-success", "pl"), true); +}); + +test("does not treat a configured localized slug as the fallback route", () => { + assert.equal(matchesStorefrontFallbackPath("/pl/zamowienie-otrzymane/", "/order-success", "pl"), false); +}); diff --git a/apps/storefront/src/lib/routePathMatching.ts b/apps/storefront/src/lib/routePathMatching.ts new file mode 100644 index 0000000..dcc1cb6 --- /dev/null +++ b/apps/storefront/src/lib/routePathMatching.ts @@ -0,0 +1,18 @@ +function normalizePathname(pathname: string): string { + const withLeadingSlash = pathname.startsWith("/") ? pathname : `/${pathname}`; + if (withLeadingSlash === "/") return "/"; + return withLeadingSlash.replace(/\/+$/, ""); +} + +export function matchesStorefrontFallbackPath( + pathname: string, + fallback: string, + routeLanguage?: string, +): boolean { + const normalizedPathname = normalizePathname(pathname); + const normalizedFallback = normalizePathname(fallback); + if (normalizedPathname === normalizedFallback) return true; + if (!routeLanguage) return false; + + return normalizedPathname === normalizePathname(`/${routeLanguage}${normalizedFallback}`); +} diff --git a/apps/storefront/src/pages/CheckoutMockupPage.tsx b/apps/storefront/src/pages/CheckoutMockupPage.tsx index 1d848ff..7254c02 100644 --- a/apps/storefront/src/pages/CheckoutMockupPage.tsx +++ b/apps/storefront/src/pages/CheckoutMockupPage.tsx @@ -426,6 +426,7 @@ export function CheckoutMockupPage() { coupons: backendCoupons, loading: shippingLoading, error: shippingError, + syncedCartRevision, adoptCart: adoptCheckoutCart, selectMethod: selectCheckoutShippingMethod, } = useCheckoutCart(billingStoreAddress, deliveryStoreAddress, cartRevision, items); @@ -554,6 +555,10 @@ export function CheckoutMockupPage() { // through the Store API can be submitted here. Crypto still remains preview-only // until the custom gateway is fully validated on the live backend. async function handlePlaceOrder(event: MouseEvent) { + if (orderSubmitting) { + event.preventDefault(); + return; + } const canSubmitRealOrder = isBackendConfigured && ( @@ -717,11 +722,13 @@ export function CheckoutMockupPage() { const requireAuthenticatedUser = isLoggedIn; setOrderSubmitting(true); - const syncResult = await syncCartToBackend(items, { force: true, verifyForCheckout: true }); - if (!syncResult.ok) { - setOrderSubmitting(false); - setOrderError(syncResult.error); - return; + if (syncedCartRevision !== cartRevision) { + const syncResult = await syncCartToBackend(items, { verifyForCheckout: true }); + if (!syncResult.ok) { + setOrderSubmitting(false); + setOrderError(syncResult.error); + return; + } } let stripePaymentMethodId: string | undefined; @@ -833,7 +840,7 @@ export function CheckoutMockupPage() { const displayShippingMethods = mapShippingOptionsToDisplayMethods( backendShippingMethods, isBackendConfigured - ? checkoutCart && !shippingLoading && !shippingError + ? checkoutCart && !shippingError ? [DEFAULT_FREE_SHIPPING_METHOD] : [] : FALLBACK_SHIPPING_METHODS, @@ -875,7 +882,9 @@ export function CheckoutMockupPage() { : fallbackShippingValue; const taxValue = checkoutTotals ? storeApiAmount(checkoutTotals.total_tax, checkoutTotals) - : authoritativeSubtotal * 0.1; + : isBackendConfigured + ? 0 + : authoritativeSubtotal * 0.1; const discountValue = checkoutTotals ? storeApiAmount(checkoutTotals.total_discount, checkoutTotals) : 0; @@ -890,7 +899,10 @@ export function CheckoutMockupPage() { label: t("checkout.shipping"), value: shouldHideShipping ? t("cart.digital_delivery") : shippingValue === 0 ? t("checkout.free") : formatBaseAmount(shippingValue), }, - { label: t("checkout.tax"), value: formatBaseAmount(taxValue) }, + { + label: t("checkout.tax"), + value: isBackendConfigured && !checkoutTotals ? "—" : formatBaseAmount(taxValue), + }, ]; const couponSection = ( diff --git a/packages/ui/src/catalog/ProductCard.tsx b/packages/ui/src/catalog/ProductCard.tsx index 4367533..f38c2d1 100644 --- a/packages/ui/src/catalog/ProductCard.tsx +++ b/packages/ui/src/catalog/ProductCard.tsx @@ -6,6 +6,7 @@ import { useCart, useSoundUX, useToast, useWishlist } from "../state"; import { savedListEntityId } from "../state/savedListSync"; import { calculateDiscountPercent, useCurrency, useT } from "../locale"; import { ProductQuickViewModal } from "./ProductQuickViewModal"; +import { shouldShowProductLearnMore } from "./productCardCta"; import { hasProductCardPrice } from "./productCardPrice"; import { resolveVariationSwatchColor } from "./variationSwatch"; export { resolveVariationSwatchColor } from "./variationSwatch"; @@ -190,15 +191,7 @@ export function ProductCard({ priceRangeLabel: convertedRangeLabel, variationPriceAmounts: variationAmounts, }); - const usesAddToCartAction = - product.productType !== "external" && - product.productType !== "grouped" && - !(product.productType === "variable" && !product.variations?.length); - // A variable product that's entirely out of stock (every variation unavailable) has - // nothing purchasable to add to cart — send shoppers to the product page instead of a - // dead-end "Add to cart"/"Choose options" action that only ever shows a toast. - const isOutOfStockVariable = product.productType === "variable" && product.inStock === false; - const showLearnMore = usesAddToCartAction && (!hasPrice || isOutOfStockVariable); + const showLearnMore = shouldShowProductLearnMore(product, hasPrice); const discountPercent = convertedRangeLabel && !selectedVariation ? null diff --git a/packages/ui/src/catalog/ProductQuickViewModal.tsx b/packages/ui/src/catalog/ProductQuickViewModal.tsx index c01f27a..941ff1c 100644 --- a/packages/ui/src/catalog/ProductQuickViewModal.tsx +++ b/packages/ui/src/catalog/ProductQuickViewModal.tsx @@ -6,6 +6,7 @@ import { useCart, useSoundUX, useToast } from "../state"; import { useCurrency, useT } from "../locale"; import type { ProductCardData } from "./ProductCard"; import { ResponsiveImage } from "../media"; +import { shouldShowProductLearnMore } from "./productCardCta"; import { hasProductCardPrice } from "./productCardPrice"; export type ProductQuickViewModalProps = { @@ -40,7 +41,7 @@ export function ProductQuickViewModal({ product, onClose }: ProductQuickViewModa priceRangeLabel: rangeLabel, variationPriceAmounts: variationAmounts, }); - const showLearnMore = product.productType !== "external" && product.productType !== "grouped" && product.productType !== "variable" && !hasPrice; + const showLearnMore = shouldShowProductLearnMore(product, hasPrice); const ctaLabel = showLearnMore ? t("product.cta.learn_more") diff --git a/packages/ui/src/catalog/productCardCta.ts b/packages/ui/src/catalog/productCardCta.ts new file mode 100644 index 0000000..b7e2cf9 --- /dev/null +++ b/packages/ui/src/catalog/productCardCta.ts @@ -0,0 +1,20 @@ +import type { ProductType } from "./ProductCard"; + +type ProductCtaData = { + productType?: ProductType; + inStock?: boolean; + variations?: Array<{ inStock: boolean }>; +}; + +export function isOutOfStockVariableProduct(product: ProductCtaData): boolean { + if (product.productType !== "variable") return false; + if (product.inStock === false) return true; + + return Boolean(product.variations?.length && product.variations.every((variation) => !variation.inStock)); +} + +export function shouldShowProductLearnMore(product: ProductCtaData, hasPrice: boolean): boolean { + if (product.productType === "external" || product.productType === "grouped") return false; + + return !hasPrice || isOutOfStockVariableProduct(product); +} diff --git a/packages/ui/src/catalog/productCardStock.test.ts b/packages/ui/src/catalog/productCardStock.test.ts index e9bb9cf..adb62ad 100644 --- a/packages/ui/src/catalog/productCardStock.test.ts +++ b/packages/ui/src/catalog/productCardStock.test.ts @@ -1,30 +1,42 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import test from "node:test"; +import { isOutOfStockVariableProduct, shouldShowProductLearnMore } from "./productCardCta.ts"; -const card = readFileSync(new URL("./ProductCard.tsx", import.meta.url), "utf8"); +test("an explicitly out-of-stock variable product uses Learn more without loaded variations", () => { + const product = { + productType: "variable" as const, + inStock: false, + }; -test("a fully out-of-stock variable product uses the Learn more CTA and product link", () => { - // Every variation being unavailable leaves nothing purchasable — the card must offer - // navigation to the full product page instead of an "Add to cart"/"Choose options" - // action whose only effect is a "Variation unavailable" toast. - assert.match( - card, - /const isOutOfStockVariable = product\.productType === "variable" && product\.inStock === false;/, - ); - assert.match( - card, - /const showLearnMore = usesAddToCartAction && \(!hasPrice \|\| isOutOfStockVariable\);/, - ); + assert.equal(isOutOfStockVariableProduct(product), true); + assert.equal(shouldShowProductLearnMore(product, true), true); +}); + +test("a variable product with no purchasable variations uses Learn more", () => { + const product = { + productType: "variable" as const, + variations: [ + { inStock: false }, + { inStock: false }, + ], + }; + + assert.equal(isOutOfStockVariableProduct(product), true); + assert.equal(shouldShowProductLearnMore(product, true), true); +}); + +test("an in-stock variable product keeps the options CTA", () => { + const product = { + productType: "variable" as const, + inStock: true, + variations: [{ inStock: true }], + }; + + assert.equal(isOutOfStockVariableProduct(product), false); + assert.equal(shouldShowProductLearnMore(product, true), false); +}); - // The CTA render branch already resolves "Learn more" + a real to the product - // page whenever `showLearnMore` is true, so feeding the out-of-stock case into that - // same flag is enough to fix both the label and the navigation behavior. - assert.match(card, /showLearnMore\s*\?\s*t\("product\.cta\.learn_more"\)/); - const ctaMarkup = card.slice(card.indexOf("{!isSimple ? ("), card.indexOf("{quickViewEnabled && isQuickViewOpen")); - assert.match( - ctaMarkup, - /showLearnMore \|\| product\.productType === "external" \|\| product\.productType === "grouped" \? \(\s* { + assert.equal(shouldShowProductLearnMore({ productType: "external" }, false), false); + assert.equal(shouldShowProductLearnMore({ productType: "grouped" }, false), false); });