14 "js/wix/cart-core.js": "// Declarations of cart-core.ts: what this file exports, their arguments and what they return.\n// import type { Cart, CartAmount, CartLine } from \"./storefront-types.js\";\n// import type { ImgSrc, Raw } from \"./catalog-core.js\";\n// export declare const WIX_STORES_APP_ID = \"215238eb-22a5-4c36-9e7b-e7c08025e04e\";\n// export declare const rawId: (raw: Raw | undefined | null) => string;\n// export declare function formatMoney(money: Raw | null | undefined, currencyCode: string | null | undefined): string;\n// export declare const cartCurrency: (raw: Raw | null | undefined) => string;\n// export declare function subscriptionTerms(info: Raw | undefined): string;\n// export declare function compareAtLinePrice(pricing: Raw | undefined, quantity: number, currency: string): string | null;\n// export declare function toLine(raw: Raw, currency: string, imgSrc: ImgSrc): CartLine;\n// export interface CartTotals {\n// subtotal: string;\n// discount: string;\n// discounts: (CartAmount & {\n// scope: \"cart\" | \"item\" | \"delivery\";\n// })[];\n// fees: CartAmount[];\n// taxes: CartAmount[];\n// pricesIncludeTax: boolean;\n// total: string;\n// }\n// export declare const EMPTY_TOTALS: CartTotals;\n// export declare function toCart(raw: Raw | null, totals: CartTotals, imgSrc: ImgSrc): Cart;\n// export declare function summaryTotals(estimate: Raw | null | undefined, raw: Raw): CartTotals;\n// export declare function addOptions({ variantId, optionChoices, modifierChoices, customTextFields, subscriptionOptionId, preorder }: {\n// variantId?: string | null;\n// optionChoices?: Record<string, string>;\n// modifierChoices?: Record<string, string>;\n// customTextFields?: Record<string, string>;\n// subscriptionOptionId?: string;\n// preorder?: boolean;\n// }): Raw;\n// export declare function errorCode(err: unknown): string | undefined;\n// export declare function cartErrorMessage(err: unknown, fallback?: string): string;\n// export declare function assertAdded(cart: Raw | null | undefined, productId: string, variantId?: string | null): void;\n// export declare function assertCheckoutable(raw: Raw | null): void;\n/** Public app id of the Wix Stores catalog — required inside every catalogReference. */\nexport const WIX_STORES_APP_ID = \"215238eb-22a5-4c36-9e7b-e7c08025e04e\";\nexport const rawId = (raw) => raw?._id ?? raw?.id ?? \"\";\n/**\n * Cart V2 money is ConvertedMoney { amount, convertedAmount } with NO formatted string, and the\n * currency lives on the cart, not on the money. Format with the buyer's display currency when\n * present, else the site's. Never hardcode \"$\" or assume USD.\n */\nexport function formatMoney(money, currencyCode) {\n const value = money?.convertedAmount ?? money?.amount;\n if (value == null || value === \"\")\n return \"\";\n const currency = currencyCode || \"USD\";\n try {\n return new Intl.NumberFormat(undefined, { style: \"currency\", currency }).format(Number(value));\n }\n catch {\n return `${value} ${currency}`;\n }\n}\nconst amountOf = (money) => {\n const n = Number(money?.convertedAmount ?? money?.amount);\n return Number.isFinite(n) ? n : 0;\n};\nexport const cartCurrency = (raw) => raw?.customerInfo?.currencyCode ?? raw?.businessInfo?.currencyCode ?? \"\";\n/** A TranslatableString / DescriptionLineName in the buyer's language, else the merchant's. */\nconst translated = (t) => typeof t === \"string\" ? t : t?.translated ?? t?.original ?? \"\";\n/**\n * \"Monthly plan · every 2 months · 6 payments\" from the line's `attributes.subscriptionInfo`\n * (where Cart V2 carries the plan); \"\" for a one-time purchase. Every word comes from the plan —\n * a subscription line must read as one in the cart, never a generic \"recurring\".\n */\nexport function subscriptionTerms(info) {\n if (!info)\n return \"\";\n const s = info.subscriptionSettings ?? {};\n const unit = { DAY: \"day\", WEEK: \"week\", MONTH: \"month\", YEAR: \"year\" };\n const parts = [];\n const title = translated(info.title);\n if (title)\n parts.push(title);\n const u = unit[String(s.frequency ?? \"\")];\n if (u)\n parts.push(`every ${s.interval && s.interval > 1 ? `${s.interval} ${u}s` : u}`);\n if (!s.autoRenewal && s.billingCycles)\n parts.push(`${s.billingCycles} payments`);\n return parts.join(\" · \");\n}\n/**\n * The catalog price to strike on a discounted line: the sale price when the buyer pays less than\n * it, else the full price when the buyer pays less than that (an automatic discount or a coupon\n * on a sale item), × quantity. null when the line isn't discounted.\n */\nexport function compareAtLinePrice(pricing, quantity, currency) {\n const unit = amountOf(pricing?.unitPrice);\n const b = pricing?.breakdown ?? {};\n const candidate = [b.salePrice, b.fullPrice].find((m) => m && amountOf(m) > unit);\n if (!candidate || quantity < 1)\n return null;\n return formatMoney({ amount: String(amountOf(candidate) * quantity) }, currency);\n}\nexport function toLine(raw, currency, imgSrc) {\n const quantity = raw.quantityInfo?.confirmedQuantity ?? 0;\n const url = raw.attributes?.url;\n return {\n lineItemId: rawId(raw), // the LINE id — what update/remove take, never the product id\n productName: translated(raw.name),\n quantity,\n unitPrice: formatMoney(raw.pricing?.unitPrice, currency),\n linePrice: formatMoney(raw.pricing?.totalPrice, currency),\n compareAtLinePrice: compareAtLinePrice(raw.pricing, quantity, currency),\n availableQuantity: typeof raw.quantityInfo?.availableQuantity === \"number\" ? raw.quantityInfo.availableQuantity : null,\n imageUrl: imgSrc(raw.attributes?.image, 300, 300),\n productUrl: typeof url === \"string\" ? url : url?.url ?? \"\",\n descriptionLines: (raw.attributes?.descriptionLines ?? [])\n .map((d) => {\n const label = translated(d.name), value = translated(d.plainText) || translated(d.colorInfo);\n return label && value ? `${label}: ${value}` : value || label;\n })\n .filter(Boolean),\n status: raw.status ?? \"IN_STOCK\", // not IN_STOCK → the line can't be checked out as-is\n subscription: subscriptionTerms(raw.attributes?.subscriptionInfo),\n };\n}\nexport const EMPTY_TOTALS = { subtotal: \"\", discount: \"\", discounts: [], fees: [], taxes: [], pricesIncludeTax: false, total: \"\" };\nexport function toCart(raw, totals, imgSrc) {\n const currency = cartCurrency(raw);\n const lines = (raw?.lineItems ?? []).map((l) => toLine(l, currency, imgSrc));\n const coupon = (raw?.coupons ?? [])[0];\n return {\n lines,\n itemCount: lines.reduce((n, l) => n + l.quantity, 0),\n subtotal: totals.subtotal,\n discount: totals.discount,\n // item-level discounts already live inside the line prices (the struck compareAtLinePrice)\n discounts: totals.discounts.filter((d) => d.scope !== \"item\").map(({ name, amount }) => ({ name, amount })),\n fees: totals.fees,\n taxes: totals.taxes,\n pricesIncludeTax: totals.pricesIncludeTax || raw?.taxInfo?.pricesIncludeTax === true,\n total: totals.total,\n coupon: coupon && rawId(coupon) ? { id: rawId(coupon), code: coupon.code ?? \"\" } : null,\n note: raw?.note ?? \"\",\n currency,\n };\n}\n/**\n * The totals from a cart estimate: the after-discount subtotal, the CART-level discount, every\n * named discount / additional fee / tax, whether prices already include tax, and the total before\n * delivery. Item-scope discounts are marked so the cart can skip them (already in the lines).\n */\nexport function summaryTotals(estimate, raw) {\n const s = estimate?.summary ?? {};\n const currency = cartCurrency(raw) || cartCurrency(estimate?.cart);\n const ps = s.priceSummary ?? {};\n const named = (list, key) => (list ?? [])\n .filter((x) => x && amountOf(x[key]) > 0)\n .map((x) => ({ name: translated(x.name) || translated(x.translatedName), amount: formatMoney(x[key], currency) }));\n const scopeOf = (d) => (d.scope === \"LINE_ITEM\" ? \"item\" : d.scope === \"DELIVERY\" ? \"delivery\" : \"cart\");\n return {\n subtotal: formatMoney(ps.subtotal, currency),\n discount: amountOf(ps.discount) > 0 ? formatMoney(ps.discount, currency) : \"\",\n discounts: (s.discounts ?? []).filter((d) => d && amountOf(d.total) > 0).map((d) => ({ name: translated(d.name), amount: formatMoney(d.total, currency), scope: scopeOf(d) })),\n fees: named(s.additionalFees, \"price\"),\n taxes: named(s.taxSummary?.taxes, \"amount\"),\n pricesIncludeTax: s.taxSummary?.pricesIncludeTax === true,\n total: formatMoney(ps.total, currency),\n };\n}\n/**\n * The catalogReference.options object for an add — omits every key the buyer didn't use. Wix's\n * own storefront sends the variant id AND every option's `key: choice.key` pair (plus the choice\n * modifiers' pairs) under `options`; free text goes under `customTextFields` by the free-text key;\n * `subscriptionOptionId` only for a recurring plan.\n */\nexport function addOptions({ variantId, optionChoices, modifierChoices, customTextFields, subscriptionOptionId, preorder }) {\n const o = {};\n if (variantId)\n o.variantId = variantId; // REQUIRED for any product with options\n const options = { ...(optionChoices ?? {}), ...(modifierChoices ?? {}) };\n if (Object.keys(options).length)\n o.options = options;\n if (customTextFields && Object.keys(customTextFields).length)\n o.customTextFields = customTextFields;\n if (subscriptionOptionId)\n o.subscriptionOptionId = subscriptionOptionId;\n if (preorder)\n o.preOrderRequested = true;\n return o;\n}\n/** The application error code of an SDK or REST failure (`details.applicationError.code`), if any. */\nexport function errorCode(err) {\n const e = err;\n const code = e?.details?.applicationError?.code ?? e?.code;\n return typeof code === \"string\" ? code : undefined;\n}\n// Buyer copy for the codes Wix's own storefront maps; anything else keeps the API's message.\nconst CART_ERRORS = {\n ITEM_NOT_FOUND_IN_CATALOG: \"This item is no longer available.\",\n INSUFFICIENT_INVENTORY: \"This item is now out of stock.\",\n ERROR_COUPON_DOES_NOT_EXIST: \"That code doesn't exist. Check it and try again.\",\n ERROR_COUPON_EXPIRED: \"That code has expired.\",\n ERROR_COUPON_NOT_APPLICABLE: \"That code doesn't apply to this cart.\",\n};\n/** A buyer-facing message for a failed cart call. */\nexport function cartErrorMessage(err, fallback = \"Something went wrong with your cart. Please try again.\") {\n const code = errorCode(err);\n if (code && CART_ERRORS[code])\n return CART_ERRORS[code];\n const message = err instanceof Error ? err.message : typeof err === \"string\" ? err : \"\";\n return message || fallback;\n}\n/** Read the add result — a refused add still returns 200: a line whose status isn't IN_STOCK, or no line, is a refusal. */\nexport function assertAdded(cart, productId, variantId) {\n const line = (cart?.lineItems ?? []).find((l) => l.source?.catalogReference?.catalogItemId === productId && (!variantId || l.source?.catalogReference?.options?.variantId === variantId));\n if (line?.status && line.status !== \"IN_STOCK\") {\n throw new Error(`This item isn't available right now (${String(line.status).toLowerCase().replace(/_/g, \" \")}).`);\n }\n if (!line || line.quantityInfo?.confirmedQuantity === 0) {\n throw new Error(\"The item couldn't be added. Make sure every required selection was made (options for a product with variants, and all mandatory customizations).\");\n }\n}\n/** Refuse checkout for an empty cart or any line not IN_STOCK — say which. */\nexport function assertCheckoutable(raw) {\n const lines = raw?.lineItems ?? [];\n if (!lines.length)\n throw new Error(\"Your cart is empty.\");\n const unavailable = lines.filter((l) => l.status && l.status !== \"IN_STOCK\");\n if (unavailable.length) {\n throw new Error(`Some items are no longer available: ${unavailable.map((l) => translated(l.name)).filter(Boolean).join(\", \")}.`);\n }\n if (!rawId(raw))\n throw new Error(\"Checkout couldn't start: the cart has no id.\");\n}\n",
15 "js/wix/cart-store.js": "// Declarations of cart-store.ts: what this file exports, their arguments and what they return.\n// import type { Cart } from \"./storefront-types.js\";\n// import { type AddToCartExtras } from \"./cart.js\";\n// export interface CartState {\n// cart: Cart | null;\n// busy: boolean;\n// pendingProductId: string | null;\n// error: string | null;\n// open: boolean;\n// }\n// export declare function getCartState(): CartState;\n// export declare function subscribeCart(fn: (state: CartState) => void): () => void;\n// export declare function refreshCart(): Promise<void>;\n// export declare function addLine(productId: string, variantId?: string | null, quantity?: number, extras?: AddToCartExtras): Promise<void>;\n// export declare function updateLineQuantity(lineItemId: string, quantity: number): Promise<void>;\n// export declare function removeCartLine(lineItemId: string): Promise<void>;\n// export declare function applyCartCoupon(code: string): Promise<void>;\n// export declare function removeCartCoupon(): Promise<void>;\n// export declare function setCartNote(note: string): Promise<void>;\n// export declare function goToCheckout(): Promise<void>;\n// export declare function setCartOpen(open: boolean): void;\nimport { addToCart as apiAdd, applyCoupon as apiApplyCoupon, fetchCart, removeCoupon as apiRemoveCoupon, removeLine as apiRemove, setNote as apiSetNote, updateQuantity as apiUpdate, checkoutUrl, } from \"./cart.js\";\nconst EMPTY = { cart: null, busy: false, pendingProductId: null, error: null, open: false };\nlet state = EMPTY;\nconst listeners = new Set();\nlet loaded = false;\nfunction setState(patch) {\n state = { ...state, ...patch };\n for (const l of listeners)\n l();\n}\nexport function getCartState() {\n return state;\n}\nexport function subscribeCart(fn) {\n const listener = () => { try {\n fn(getCartState());\n }\n catch (e) {\n console.error(\"cart-store listener failed\", e);\n } };\n listeners.add(listener);\n // First subscriber triggers the initial load (browser only — SSR renders the empty state).\n if (!loaded && typeof window !== \"undefined\") {\n loaded = true;\n void refreshCart();\n }\n return () => listeners.delete(listener);\n}\nasync function run(op) {\n setState({ busy: true, error: null });\n try {\n setState({ cart: await op(), busy: false });\n }\n catch (e) {\n setState({ busy: false, error: e instanceof Error ? e.message : String(e) });\n throw e;\n }\n}\nexport async function refreshCart() {\n try {\n setState({ cart: await fetchCart() });\n }\n catch {\n /* no cart yet — leave the empty state */\n }\n}\n/** Add to cart and open the drawer. Throws (and sets .error) on refusal. */\nexport async function addLine(productId, variantId, quantity = 1, extras) {\n setState({ pendingProductId: productId });\n try {\n await run(() => apiAdd(productId, variantId, quantity, extras));\n }\n finally {\n setState({ pendingProductId: null });\n // Open either way. On success this shows the new line; on refusal it's the only thing that\n // makes .error visible — the drawer renders it, and a drawer that stays shut on failure\n // turns a refused add (out of stock, a digital product with no file) into silence.\n setState({ open: true });\n }\n}\nexport async function updateLineQuantity(lineItemId, quantity) {\n await run(() => apiUpdate(lineItemId, quantity));\n}\nexport async function removeCartLine(lineItemId) {\n await run(() => apiRemove(lineItemId));\n}\n/** Apply a coupon code; rejects (and sets .error with buyer copy) when the code is unknown, expired, or doesn't apply. */\nexport async function applyCartCoupon(code) {\n if (!code.trim())\n return;\n await run(() => apiApplyCoupon(code));\n}\nexport async function removeCartCoupon() {\n await run(() => apiRemoveCoupon());\n}\n/** Save the buyer's note to the merchant (commit on blur, not on every keystroke). */\nexport async function setCartNote(note) {\n if ((state.cart?.note ?? \"\") === note.trim())\n return;\n await run(() => apiSetNote(note));\n}\n/** Navigate to the Wix-hosted checkout. Never hand-build a checkout URL. */\nexport async function goToCheckout() {\n setState({ busy: true, error: null });\n try {\n const url = await checkoutUrl();\n window.location.href = url;\n }\n catch (e) {\n setState({ busy: false, error: e instanceof Error ? e.message : String(e) });\n throw e;\n }\n}\nexport function setCartOpen(open) {\n setState({ open });\n}\n",
16 "js/wix/cart.js": "// Declarations of cart.ts: what this file exports, their arguments and what they return.\n// import type { Cart } from \"./storefront-types.js\";\n// export interface AddToCartExtras {\n// optionChoices?: Record<string, string>;\n// modifierChoices?: Record<string, string>;\n// customTextFields?: Record<string, string>;\n// subscriptionOptionId?: string;\n// preorder?: boolean;\n// }\n// export declare function fetchCart(): Promise<Cart>;\n// export declare function addToCart(productId: string, variantId?: string | null, quantity?: number, extras?: AddToCartExtras): Promise<Cart>;\n// export declare function updateQuantity(lineItemId: string, quantity: number): Promise<Cart>;\n// export declare function removeLine(lineItemId: string): Promise<Cart>;\n// export declare function applyCoupon(code: string): Promise<Cart>;\n// export declare function removeCoupon(): Promise<Cart>;\n// export declare function setNote(note: string): Promise<Cart>;\n// export declare function checkoutUrl(origin?: string): Promise<string>;\n// Cart + checkout over REST (Wix eCom Cart V2) — the twin of app/wix/storefront/cart.ts. Same\n// exports, same Cart/CartLine DTOs; rules and mappers from cart-core (the SAME file the SDK\n// transport uses, deployed flat next to this one). The request shapes are exact and rewriting\n// them is how carts break. Failures are loud: out-of-stock lines, an empty cart at checkout, a\n// missing required selection, a bad coupon all throw buyer copy — surface the message to the buyer.\n// All calls run with the visitor token: the cart is the token's (see ../shared/client).\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/add-line-items-to-current-cart.md\n// docs: https://dev.wix.com/docs/api-reference/business-management/headless/redirects/create-redirect-session.md\nimport { WixApiError, wixRequest } from \"./client.js\";\nimport { imgSrc } from \"./media.js\";\nimport { EMPTY_TOTALS, WIX_STORES_APP_ID, addOptions, assertAdded, assertCheckoutable, cartErrorMessage, rawId, summaryTotals, toCart } from \"./cart-core.js\";\nconst CART = \"/ecom/v2/carts/current\";\n// Every cart call fails with buyer copy (cart-core maps Wix's codes; the rest keep their message).\nasync function loud(op) {\n try {\n return await op();\n }\n catch (e) {\n throw new Error(cartErrorMessage(e));\n }\n}\n// A line's attributes.image comes back absent right after an add (verified live) — join the\n// products' main images by catalogItemId, once per product, cached for the session.\n// POST /stores/v3/products/query { query: { filter: { id: { $in: [...] } } } }\nconst lineImageCache = new Map();\nasync function fillLineImages(cart, raws) {\n const wanted = new Map();\n raws.forEach((raw, i) => {\n if (cart.lines[i].imageUrl)\n return;\n const pid = raw.source?.catalogReference?.catalogItemId;\n if (!pid)\n return;\n const cached = lineImageCache.get(pid);\n if (cached !== undefined) {\n cart.lines[i].imageUrl = cached;\n return;\n }\n wanted.set(pid, [...(wanted.get(pid) ?? []), i]);\n });\n if (!wanted.size)\n return;\n try {\n const res = await wixRequest(\"/stores/v3/products/query\", { body: { query: { filter: { id: { $in: [...wanted.keys()] } }, cursorPaging: { limit: 100 } } } });\n for (const p of (res?.products ?? [])) {\n const url = imgSrc(p.media?.main, 300, 300);\n lineImageCache.set(rawId(p), url);\n for (const i of wanted.get(rawId(p)) ?? [])\n cart.lines[i].imageUrl = url;\n }\n }\n catch {\n /* images are a nicety — the cart stays correct without them */\n }\n}\nasync function readCart(raw) {\n // Only estimate a cart WITH lines — on an absent/empty cart the endpoint returns 404.\n if (!raw?.lineItems?.length)\n return toCart(raw, EMPTY_TOTALS, imgSrc);\n // The totals come from the estimate, never from hand-summing lines: the after-discount subtotal,\n // named discounts, fees and taxes (calculated when asked), the total before delivery. Delivery\n // resolves at checkout — never render it as \"Free\" here.\n // POST /ecom/v2/carts/current/estimate { calculateTax, calculateAdditionalFees }\n let totals = EMPTY_TOTALS;\n try {\n totals = summaryTotals(await wixRequest(`${CART}/estimate`, { body: { calculateTax: true, calculateAdditionalFees: true } }), raw);\n }\n catch {\n /* the estimate is a display nicety — the cart itself is still valid */\n }\n const cart = toCart(raw, totals, imgSrc);\n await fillLineImages(cart, raw.lineItems);\n return cart;\n}\n// GET /ecom/v2/carts/current — a 404 (CURRENT_CART_NOT_FOUND / OWNED_CART_NOT_FOUND) means no cart yet.\nasync function getCurrentCartRaw() {\n try {\n return (await wixRequest(CART, { method: \"GET\" }))?.cart ?? null;\n }\n catch (e) {\n if (e instanceof WixApiError && e.status === 404)\n return null;\n throw e;\n }\n}\n/** The visitor's current cart. An empty Cart (not an error) when none exists yet. */\nexport async function fetchCart() {\n return readCart(await getCurrentCartRaw());\n}\n/**\n * Add a product. `variantId` is REQUIRED for any product with options — resolve it first\n * (resolveVariant); a refused add still returns 200, so the returned line is checked.\n * POST /ecom/v2/carts/current/add-line-items { catalogItems: [{ quantity, catalogReference: { appId, catalogItemId, options } }] }\n */\nexport async function addToCart(productId, variantId, quantity = 1, extras = {}) {\n return loud(async () => {\n const options = addOptions({ variantId, ...extras });\n const res = await wixRequest(`${CART}/add-line-items`, {\n body: { catalogItems: [{ quantity, catalogReference: { catalogItemId: productId, appId: WIX_STORES_APP_ID, ...(Object.keys(options).length ? { options } : {}) } }] },\n });\n assertAdded(res?.cart, productId, variantId);\n return readCart(res?.cart ?? null);\n });\n}\n/**\n * Change a line's quantity. `lineItemId` is CartLine.lineItemId, never the product id; a flat\n * { id, quantity } is a 400. POST /ecom/v2/carts/current/update-line-items { lineItems: [{ lineItemId, quantity: { newQuantity } }] }\n */\nexport async function updateQuantity(lineItemId, quantity) {\n return loud(async () => {\n const res = await wixRequest(`${CART}/update-line-items`, { body: { lineItems: [{ lineItemId, quantity: { newQuantity: quantity } }] } });\n return readCart(res?.cart ?? null);\n });\n}\n/** Remove a line by CartLine.lineItemId. POST /ecom/v2/carts/current/remove-line-items { lineItemIds } */\nexport async function removeLine(lineItemId) {\n return loud(async () => {\n const res = await wixRequest(`${CART}/remove-line-items`, { body: { lineItemIds: [lineItemId] } });\n return readCart(res?.cart ?? null);\n });\n}\n/**\n * Apply a coupon code (one per cart); buyer copy for an unknown, expired, or inapplicable code.\n * POST /ecom/v2/carts/current/add-coupon { coupon: { code } }\n */\nexport async function applyCoupon(code) {\n return loud(async () => {\n const res = await wixRequest(`${CART}/add-coupon`, { body: { coupon: { code: code.trim() } } });\n return readCart(res?.cart ?? null);\n });\n}\n/** Remove the applied coupon (a no-op when none). POST /ecom/v2/carts/current/remove-coupon { couponId } */\nexport async function removeCoupon() {\n return loud(async () => {\n const current = await getCurrentCartRaw();\n const couponId = rawId((current?.coupons ?? [])[0]);\n if (!couponId)\n return readCart(current);\n const res = await wixRequest(`${CART}/remove-coupon`, { body: { couponId } });\n return readCart(res?.cart ?? null);\n });\n}\n/**\n * Save the buyer's note to the merchant on the cart (it travels to checkout and the order).\n * PATCH /ecom/v2/carts/current { cart: { note } }\n */\nexport async function setNote(note) {\n return loud(async () => {\n const res = await wixRequest(CART, { method: \"PATCH\", body: { cart: { note: note.trim() || null } } });\n return readCart(res?.cart ?? null);\n });\n}\n/**\n * Start the Wix-hosted checkout and return the URL to navigate the FULL document to. The cart's id\n * IS the checkout id (as on Wix's own storefront). `origin` must be the site's real https origin as\n * registered on the OAuth app's allowed domains (browser: window.location.origin; server: the\n * public https host, never the request's internal origin) — an unlisted or http origin 403s on return.\n * POST /headless/v1/redirect-session { ecomCheckout: { checkoutId }, callbacks: { postFlowUrl, thankYouPageUrl } }\n */\nexport async function checkoutUrl(origin = typeof window !== \"undefined\" ? window.location.origin : \"\") {\n const raw = await getCurrentCartRaw();\n assertCheckoutable(raw);\n const res = await wixRequest(\"/headless/v1/redirect-session\", {\n body: { ecomCheckout: { checkoutId: rawId(raw) }, callbacks: origin ? { postFlowUrl: `${origin}/`, thankYouPageUrl: `${origin}/` } : {} },\n });\n const url = res?.redirectSession?.fullUrl;\n if (!url)\n throw new Error(\"Checkout couldn't start: no redirect URL returned.\");\n return url;\n}\n",
17 "js/wix/catalog-core.js": "// Declarations of catalog-core.ts: what this file exports, their arguments and what they return.\n// import type { Availability, Breadcrumb, Category, Facet, FacetData, ProductDetail, ProductModifier, ProductOption, ProductSummary, ProductVariant, SubscriptionPlan, VariantInventory } from \"./storefront-types.js\";\n// export type Raw = Record<string, any>;\n// export type ImgSrc = (value: any, width: number, height: number) => string;\n// export type MediaKey = (value: any) => string;\n// export declare const LIST_FIELDS: readonly [\"CURRENCY\", \"MEDIA_ITEMS_INFO\", \"MIN_PRICE_VARIANT\", \"DISCOUNT_INFO\"];\n// export declare const DETAIL_FIELDS: readonly [\"CURRENCY\", \"MEDIA_ITEMS_INFO\", \"MIN_PRICE_VARIANT\", \"DISCOUNT_INFO\", \"PLAIN_DESCRIPTION\", \"VARIANT_OPTION_CHOICE_NAMES\", \"INFO_SECTION\", \"INFO_SECTION_PLAIN_DESCRIPTION\", \"SUBSCRIPTION_PRICES_INFO\", \"BREADCRUMBS_INFO\", \"DIRECT_CATEGORIES_INFO\", \"PRODUCT_CHOICES_MEDIA_REFERENCES\"];\n// export declare const STORES_TREE: {\n// readonly appNamespace: \"@wix/stores\";\n// readonly treeKey: null;\n// };\n// export declare const CATALOG_SORTS: {\n// readonly featured: {\n// readonly label: \"Default order\";\n// };\n// readonly priceAsc: {\n// readonly label: \"Price: low to high\";\n// };\n// readonly priceHigh: {\n// readonly label: \"Price: high to low\";\n// };\n// readonly name: {\n// readonly label: \"Name: A–Z\";\n// };\n// readonly nameDesc: {\n// readonly label: \"Name: Z–A\";\n// };\n// readonly newest: {\n// readonly label: \"Newest\";\n// };\n// };\n// export type CatalogSort = keyof typeof CATALOG_SORTS;\n// export declare const SORT_FIELDS: Partial<Record<CatalogSort, {\n// fieldName: string;\n// order: \"ASC\" | \"DESC\";\n// }[]>>;\n// export declare const OPTION_CHOICE_FIELD = \"options.choicesSettings.choices.choiceId\";\n// export declare const MODIFIER_CHOICE_FIELD = \"modifiers.choicesSettings.choices.choiceId\";\n// export interface FacetSelection {\n// id: string;\n// kind: \"option\" | \"modifier\";\n// choiceIds: string[];\n// }\n// export interface CatalogSearchOptions {\n// limit?: number;\n// cursor?: string | null;\n// categoryId?: string | null;\n// sort?: CatalogSort;\n// minPrice?: number | string;\n// maxPrice?: number | string;\n// inStockOnly?: boolean;\n// search?: string;\n// facetSelections?: FacetSelection[];\n// }\n// export declare function priceBound(value: number | string | undefined, name: string): number | undefined;\n// export declare function catalogFilter({ categoryId, minPrice, maxPrice, inStockOnly, facetSelections }?: CatalogSearchOptions): Raw;\n// export declare function searchQuery(o: CatalogSearchOptions): Raw;\n// export declare function pageInfo(res: Raw | null | undefined): {\n// nextCursor: string | null;\n// hasMore: boolean;\n// };\n// export declare const FACET_AGGREGATIONS: Raw[];\n// export declare function facetSearchBody(categoryId?: string | null): Raw;\n// export interface FacetAggregates {\n// minPrice: number | null;\n// maxPrice: number | null;\n// optionIds: string[];\n// modifierIds: string[];\n// choiceCounts: Record<string, number>;\n// }\n// export declare function readFacetAggregates(res: Raw | null | undefined): FacetAggregates;\n// export declare function facetCustomizationIds(agg: FacetAggregates): string[];\n// export declare function toFacet(customization: Raw, kind: Facet[\"kind\"], choiceCounts: Record<string, number>): Facet;\n// export declare function toFacetData(agg: FacetAggregates, customizations: Raw[]): FacetData;\n// export declare function expandFacetChoiceIds(facet: Facet | undefined, choiceIds: string[]): string[];\n// export declare function sellingPrice(price: Raw | undefined): {\n// current?: Raw;\n// original?: Raw;\n// };\n// export declare function strike(original: Raw | undefined, current: Raw | undefined): string | null;\n// export declare function ribbonsOf(raw: Raw): string[];\n// export declare function toAvailability(raw: Raw): Availability;\n// export declare function isPreorderProduct(raw: Raw): boolean;\n// export declare function mediaEntries(raw: Raw, mediaKey: MediaKey): unknown[];\n// export declare function toSummary(raw: Raw, imgSrc: ImgSrc, mediaKey: MediaKey): ProductSummary;\n// export declare function toOptions(raw: Raw, imgSrc?: ImgSrc): ProductOption[];\n// export declare function toModifiers(raw: Raw): ProductModifier[];\n// export declare function planTerms(frequency: string | null, interval: number, billingCycles: number | null): string;\n// export declare function toSubscriptionPlans(raw: Raw): SubscriptionPlan[];\n// export declare function toVariants(raw: Raw, imgSrc: ImgSrc): ProductVariant[];\n// export declare function toBreadcrumbs(info: Raw | undefined): Breadcrumb[];\n// export declare function toDetail(raw: Raw, imgSrc: ImgSrc, mediaKey: MediaKey): ProductDetail;\n// export declare function toCategory(raw: Raw, imgSrc: ImgSrc): Category;\n// export declare function toInventory(items: Raw[]): Record<string, VariantInventory>;\n// export declare function mergeInventory(variants: ProductVariant[], inventory: Record<string, VariantInventory>): ProductVariant[];\n// export declare function resolveVariant(detail: ProductDetail, selections: Record<string, string>): ProductVariant | null;\n// export declare function choiceAvailability(detail: ProductDetail, selections: Record<string, string>): Record<string, Record<string, {\n// inStock: boolean;\n// exists: boolean;\n// }>>;\n// export declare const MAX_QUANTITY = 99999;\n// export declare function maxPurchasable(variant: ProductVariant | null): number;\nconst id = (raw) => raw?._id ?? raw?.id ?? \"\";\n// Requested on every product read. CURRENCY → formattedAmount (without it prices render as bare\n// numbers); MEDIA_ITEMS_INFO → the gallery; MIN_PRICE_VARIANT + DISCOUNT_INFO → the cheapest variant\n// with its discounted price and the discount rule names: a card's real price and the direct-add\n// variant id, no per-product fetch.\nexport const LIST_FIELDS = [\"CURRENCY\", \"MEDIA_ITEMS_INFO\", \"MIN_PRICE_VARIANT\", \"DISCOUNT_INFO\"];\n// The detail read adds the HTML description, the variants (VARIANT_OPTION_CHOICE_NAMES — without it\n// variantsInfo is null), the info sections with HTML bodies, each variant's plan prices\n// (SUBSCRIPTION_PRICES_INFO), and the category path (BREADCRUMBS_INFO, DIRECT_CATEGORIES_INFO).\nexport const DETAIL_FIELDS = [\n ...LIST_FIELDS,\n \"PLAIN_DESCRIPTION\",\n \"VARIANT_OPTION_CHOICE_NAMES\",\n \"INFO_SECTION\",\n \"INFO_SECTION_PLAIN_DESCRIPTION\",\n \"SUBSCRIPTION_PRICES_INFO\",\n \"BREADCRUMBS_INFO\",\n \"DIRECT_CATEGORIES_INFO\",\n // the gallery image linked to each option choice (the dashboard's \"image per colour\"); variant\n // media is derived from it at creation only, so the choice is the source of truth\n \"PRODUCT_CHOICES_MEDIA_REFERENCES\",\n];\n/** Stores' category tree — every Categories API call names it. */\nexport const STORES_TREE = { appNamespace: \"@wix/stores\", treeKey: null };\n// ---- sort + filter ---------------------------------------------------------------------------------\nexport const CATALOG_SORTS = {\n featured: { label: \"Default order\" }, // the catalog's own order — no sort sent, and NOT a sales ranking\n priceAsc: { label: \"Price: low to high\" },\n priceHigh: { label: \"Price: high to low\" },\n name: { label: \"Name: A–Z\" },\n nameDesc: { label: \"Name: Z–A\" },\n newest: { label: \"Newest\" },\n};\nexport const SORT_FIELDS = {\n priceAsc: [{ fieldName: \"actualPriceRange.minValue.amount\", order: \"ASC\" }, { fieldName: \"name\", order: \"ASC\" }],\n priceHigh: [{ fieldName: \"actualPriceRange.minValue.amount\", order: \"DESC\" }, { fieldName: \"name\", order: \"ASC\" }],\n name: [{ fieldName: \"name\", order: \"ASC\" }],\n nameDesc: [{ fieldName: \"name\", order: \"DESC\" }],\n newest: [{ fieldName: \"createdDate\", order: \"DESC\" }, { fieldName: \"name\", order: \"ASC\" }],\n};\n// The search fields a product carries its customization ids under — the same paths aggregate\n// (facets) and filter (selection), so a facet always filters on the field it was counted from.\nexport const OPTION_CHOICE_FIELD = \"options.choicesSettings.choices.choiceId\";\nexport const MODIFIER_CHOICE_FIELD = \"modifiers.choicesSettings.choices.choiceId\";\nexport function priceBound(value, name) {\n if (value == null || value === \"\")\n return undefined;\n const n = Number(value);\n if (!Number.isFinite(n) || n < 0)\n throw new Error(`${name} must be a non-negative number.`);\n return n;\n}\n/**\n * The filter every catalog read shares (the page, its count, the facets) — one place, so they\n * can't drift. Search rejects two operators in ONE field object: price bounds are separate\n * conditions joined by $and. Category: `allCategoriesInfo.categories` with `$matchItems` on the\n * id (includes parent categories); never `$hasSome`, never V1 `collectionIds`. The price bounds\n * bracket the product's RANGE: its minimum at least `minPrice`, its maximum at most `maxPrice` —\n * a \"$10–$50\" filter must not admit a $12–$400 product. Facets discover PRODUCTS carrying a\n * choice; the picker / PDP still resolves the variant.\n */\nexport function catalogFilter({ categoryId, minPrice, maxPrice, inStockOnly = false, facetSelections = [] } = {}) {\n const min = priceBound(minPrice, \"minPrice\"), max = priceBound(maxPrice, \"maxPrice\");\n if (min !== undefined && max !== undefined && min > max)\n throw new Error(\"minPrice must not exceed maxPrice.\");\n const and = [{ visible: true }];\n if (categoryId)\n and.push({ \"allCategoriesInfo.categories\": { $matchItems: [{ id: categoryId }] } });\n if (min !== undefined)\n and.push({ \"actualPriceRange.minValue.amount\": { $gte: String(min) } });\n if (max !== undefined)\n and.push({ \"actualPriceRange.maxValue.amount\": { $lte: String(max) } });\n if (inStockOnly)\n and.push({ \"inventory.availabilityStatus\": { $eq: \"IN_STOCK\" } });\n for (const f of facetSelections) {\n if (!f.choiceIds.length)\n continue;\n and.push({ [f.kind === \"modifier\" ? MODIFIER_CHOICE_FIELD : OPTION_CHOICE_FIELD]: { $hasSome: f.choiceIds } });\n }\n return { $and: and };\n}\n/** The full Search Products body (minus fields) for a page; a cursor request carries ONLY cursorPaging. */\nexport function searchQuery(o) {\n const { limit = 24, cursor, sort = \"featured\", search = \"\" } = o;\n if (!Number.isInteger(limit) || limit < 1 || limit > 100)\n throw new Error(\"limit must be between 1 and 100.\");\n const paging = { cursorPaging: { limit, ...(cursor ? { cursor } : {}) } };\n if (cursor)\n return paging;\n if (!(sort in CATALOG_SORTS))\n throw new Error(\"Unsupported catalog sort.\");\n if (search.trim().length > 100)\n throw new Error(\"Search must be at most 100 characters.\");\n return {\n ...paging,\n filter: catalogFilter(o),\n ...(SORT_FIELDS[sort] ? { sort: SORT_FIELDS[sort] } : {}),\n ...(search.trim() ? { search: { expression: search.trim(), fields: [\"name\"] } } : {}),\n };\n}\n/** Read a search response's paging: `hasNext` is authoritative, the cursor is what continues. */\nexport function pageInfo(res) {\n const nextCursor = res?.pagingMetadata?.cursors?.next ?? null;\n const hasNext = res?.pagingMetadata?.hasNext;\n return { nextCursor, hasMore: typeof hasNext === \"boolean\" ? hasNext && !!nextCursor : !!nextCursor };\n}\n// ---- facets (aggregations + customizations) ---------------------------------------------------------\n// The aggregations-only search that discovers a scope's filters: price bounds as scalars, the\n// option/modifier ids and their choice ids as value buckets with counts. `cursorPaging.limit: 0`\n// returns no products — the whole catalog is summarised in one call, however many products.\nexport const FACET_AGGREGATIONS = [\n { name: \"min_price\", type: \"SCALAR\", fieldPath: \"actualPriceRange.minValue.amount\", scalar: { type: \"MIN\" } },\n { name: \"max_price\", type: \"SCALAR\", fieldPath: \"actualPriceRange.maxValue.amount\", scalar: { type: \"MAX\" } },\n { name: \"options\", type: \"VALUE\", fieldPath: \"options.id\", value: { limit: 1000, sortType: \"COUNT\", sortDirection: \"DESC\" } },\n { name: \"options_with_choice\", type: \"VALUE\", fieldPath: OPTION_CHOICE_FIELD, value: { limit: 20000, sortType: \"COUNT\", sortDirection: \"DESC\" } },\n { name: \"modifiers\", type: \"VALUE\", fieldPath: \"modifiers.id\", value: { limit: 1000, sortType: \"COUNT\", sortDirection: \"DESC\" } },\n { name: \"modifiers_with_choice\", type: \"VALUE\", fieldPath: MODIFIER_CHOICE_FIELD, value: { limit: 20000, sortType: \"COUNT\", sortDirection: \"DESC\" } },\n];\n/** The Search Products body that returns only the aggregations of a scope. */\nexport function facetSearchBody(categoryId) {\n return { filter: catalogFilter({ categoryId }), cursorPaging: { limit: 0 }, aggregations: FACET_AGGREGATIONS };\n}\nexport function readFacetAggregates(res) {\n const byName = new Map();\n for (const r of (res?.aggregationData?.results ?? []))\n if (r?.name)\n byName.set(r.name, r);\n const ids = (name) => (byName.get(name)?.values?.results ?? []).map((b) => b?.value).filter((v) => typeof v === \"string\" && v.length > 0);\n const counts = {};\n for (const name of [\"options_with_choice\", \"modifiers_with_choice\"]) {\n for (const b of (byName.get(name)?.values?.results ?? []))\n if (typeof b?.value === \"string\" && typeof b?.count === \"number\")\n counts[b.value] = b.count;\n }\n const scalar = (name) => {\n const v = byName.get(name)?.scalar?.value;\n return typeof v === \"number\" && Number.isFinite(v) ? v : null;\n };\n return { minPrice: scalar(\"min_price\"), maxPrice: scalar(\"max_price\"), optionIds: ids(\"options\"), modifierIds: ids(\"modifiers\"), choiceCounts: counts };\n}\n/** Every customization id the facets need names, colors and choice order for. */\nexport function facetCustomizationIds(agg) {\n return [...new Set([...agg.optionIds, ...agg.modifierIds])];\n}\nconst numeric = (s) => {\n const t = s.trim();\n if (!t)\n return null;\n const n = Number(t);\n return Number.isFinite(n) ? n : null;\n};\n// Names sort case-insensitively, numbers first and numerically (\"2\", \"10\", \"L\").\nconst byNameNumericFirst = (items, name) => [...items].sort((a, b) => {\n const na = numeric(name(a)), nb = numeric(name(b));\n if (na !== null && nb !== null)\n return na - nb;\n if (na !== null)\n return -1;\n if (nb !== null)\n return 1;\n return name(a).toLowerCase().localeCompare(name(b).toLowerCase());\n});\n/**\n * One facet from a customization entity and the counts the aggregation gave its choices: only\n * choices some product in the scope carries; a linked choice (\"Light red\", `primaryChoiceIds`)\n * folds into its primary with its count and rides along as a childId when the primary is picked;\n * order per the merchant's `choicesSettings.sortOrder` (BY_NAME, BY_PRODUCT_COUNT, MANUAL; the\n * default is name for text, count for swatches).\n */\nexport function toFacet(customization, kind, choiceCounts) {\n const all = customization.choicesSettings?.choices ?? [];\n const parentOf = new Map(); // childId -> primary ids\n for (const c of all)\n if (Array.isArray(c.primaryChoiceIds) && c.primaryChoiceIds.length)\n parentOf.set(id(c), c.primaryChoiceIds);\n const choices = new Map();\n const choiceOf = (c) => ({ id: id(c), name: c.name ?? \"\", colorCode: c.colorCode ?? null, count: 0, childIds: [] });\n for (const c of all) {\n const count = choiceCounts[id(c)] ?? 0;\n const primaries = parentOf.get(id(c));\n if (primaries) {\n if (!count)\n continue;\n for (const pid of primaries) {\n const primary = all.find((p) => id(p) === pid);\n if (!primary)\n continue;\n const entry = choices.get(pid) ?? choiceOf(primary);\n entry.count += count;\n entry.childIds.push(id(c));\n choices.set(pid, entry);\n }\n continue;\n }\n if (!count && !choices.has(id(c)))\n continue;\n const entry = choices.get(id(c)) ?? choiceOf(c);\n entry.count += count;\n choices.set(id(c), entry);\n }\n // a primary that only got children (no own count) surfaces through them above; drop empties\n let list = [...choices.values()].filter((c) => c.id && c.count > 0);\n const isColor = customization.customizationRenderType === \"SWATCH_CHOICES\";\n const order = customization.choicesSettings?.sortOrder ?? \"DEFAULT\";\n if (order === \"BY_NAME\" || (order === \"DEFAULT\" && !isColor))\n list = byNameNumericFirst(list, (c) => c.name);\n else if (order === \"BY_PRODUCT_COUNT\" || (order === \"DEFAULT\" && parentOf.size > 0))\n list = [...list].sort((a, b) => b.count - a.count);\n else if (order === \"MANUAL\") {\n const pos = new Map(all.map((c, i) => [id(c), i]));\n list = [...list].sort((a, b) => (pos.get(a.id) ?? 1e9) - (pos.get(b.id) ?? 1e9));\n }\n return { id: id(customization), name: customization.name ?? \"\", kind, isColor, choices: list };\n}\n/**\n * Facets + price bounds from the aggregations and the customization entities they named. Free-text\n * customizations never arrive (the query excludes them); a facet with one choice narrows nothing\n * and is dropped; swatch facets first, then text, each by name.\n */\nexport function toFacetData(agg, customizations) {\n const byId = new Map(customizations.map((c) => [id(c), c]));\n const build = (ids, kind) => ids.flatMap((cid) => {\n const c = byId.get(cid);\n if (!c || c.customizationRenderType === \"FREE_TEXT\")\n return [];\n const f = toFacet(c, kind, agg.choiceCounts);\n return f.choices.length > 1 ? [f] : [];\n });\n const facets = [...build(agg.optionIds, \"option\"), ...build(agg.modifierIds, \"modifier\")];\n const swatches = byNameNumericFirst(facets.filter((f) => f.isColor), (f) => f.name);\n const texts = byNameNumericFirst(facets.filter((f) => !f.isColor), (f) => f.name);\n const lo = agg.minPrice, hi = agg.maxPrice;\n return {\n facets: [...swatches, ...texts],\n priceRange: lo !== null && hi !== null && hi > lo ? { min: Math.floor(lo), max: Math.ceil(hi) } : null,\n };\n}\n/** The ids a selection sends for a facet: each picked choice plus the linked choices folded under it. */\nexport function expandFacetChoiceIds(facet, choiceIds) {\n const out = new Set();\n for (const cid of choiceIds) {\n out.add(cid);\n for (const child of facet?.choices.find((c) => c.id === cid)?.childIds ?? [])\n out.add(child);\n }\n return [...out];\n}\n// ---- price rules ----------------------------------------------------------------------------------\n/**\n * The price the buyer pays and the price to strike — exact precedence, shared by cards and\n * variants. An automatic discount (priceAfterDiscount) wins and strikes the regular actualPrice;\n * else actualPrice with the merchant's compareAtPrice as the \"was\". `!== undefined` on purpose:\n * a discounted price of 0 is a real price.\n */\nexport function sellingPrice(price) {\n if (price?.priceAfterDiscount !== undefined)\n return { current: price.priceAfterDiscount, original: price.actualPrice };\n return { current: price?.actualPrice, original: price?.compareAtPrice };\n}\n/** The struck price only when it is real and higher than what the buyer pays. */\nexport function strike(original, current) {\n const o = Number(original?.amount), c = Number(current?.amount);\n return original?.formattedAmount && Number.isFinite(o) && Number.isFinite(c) && o > c ? original.formattedAmount : null;\n}\n/** Every merchant ribbon, primary first, de-duplicated. A ribbon is a label, never proof of a price. */\nexport function ribbonsOf(raw) {\n return [raw.ribbon?.name, ...(raw.additionalRibbons ?? []).map((r) => r?.name)]\n .filter((n, i, all) => typeof n === \"string\" && n.length > 0 && all.indexOf(n) === i);\n}\nexport function toAvailability(raw) {\n const s = raw.inventory?.availabilityStatus;\n return s === \"OUT_OF_STOCK\" || s === \"PARTIALLY_OUT_OF_STOCK\" ? s : \"IN_STOCK\";\n}\n/**\n * The product-level pre-order state: every variant pre-orderable, or some are and the product\n * is out of stock (`inventory.preorderAvailability`). Older responses without that field fall\n * back to `preorderStatus`.\n */\nexport function isPreorderProduct(raw) {\n const availability = toAvailability(raw);\n const pa = raw.inventory?.preorderAvailability;\n if (pa)\n return pa === \"ALL_VARIANTS\" || (pa === \"SOME_VARIANTS\" && availability === \"OUT_OF_STOCK\");\n return raw.inventory?.preorderStatus === \"ENABLED\" && availability === \"OUT_OF_STOCK\";\n}\n/**\n * Every distinct media entry, main first, keyed on identity — two scaled URLs of one photo are\n * ONE entry (the \"two thumbnails for the same image\" bug). Shared by the tile and the PDP.\n */\nexport function mediaEntries(raw, mediaKey) {\n const out = [];\n const seen = new Set();\n for (const m of [raw.media?.main, ...(raw.media?.itemsInfo?.items ?? [])]) {\n const v = m?.image ?? m;\n const k = mediaKey(v);\n if (!k || seen.has(k))\n continue;\n seen.add(k);\n out.push(v);\n }\n return out;\n}\nconst visibleSubscriptions = (raw) => (raw.subscriptionDetails?.subscriptions ?? []).filter((s) => s.visible !== false);\n// ---- DTO mappers -----------------------------------------------------------------------------------\nexport function toSummary(raw, imgSrc, mediaKey) {\n const options = raw.options ?? [];\n const media = mediaEntries(raw, mediaKey);\n const mainUrl = imgSrc(media[0], 800, 800);\n const hover = media.slice(1).map((m) => imgSrc(m, 800, 800)).filter(Boolean);\n const availability = toAvailability(raw);\n // Price: the cheapest variant's selling price (discount applied); the product RANGE when variants\n // are priced differently — and NO struck price beside a range (a lone struck minimum claims a\n // saving that may not apply to the variant picked). When that cheapest variant is DISCOUNTED the\n // range's top is unknown here (actualPriceRange is pre-discount) — the tile says \"From {price}\"\n // and the PDP recomputes the range from its variants.\n const min = raw.actualPriceRange?.minValue, max = raw.actualPriceRange?.maxValue;\n const isRange = !!(min?.amount && max?.amount && min.amount !== max.amount);\n const minVariant = raw.variantSummary?.minPriceVariant;\n const { current, original } = sellingPrice(minVariant?.price);\n const discounted = minVariant?.price?.priceAfterDiscount !== undefined;\n const price = current?.formattedAmount ?? min?.formattedAmount ?? \"\";\n const fromPrice = isRange && discounted;\n const ribbons = ribbonsOf(raw);\n const hasSubscriptions = visibleSubscriptions(raw).length > 0;\n return {\n id: id(raw),\n slug: raw.slug ?? \"\",\n name: raw.name ?? \"\",\n price,\n maxPrice: fromPrice ? price : max?.formattedAmount ?? \"\",\n fromPrice,\n compareAtPrice: isRange ? null : strike(original ?? raw.compareAtPriceRange?.minValue, current ?? min),\n discountNames: (raw.discountInfo?.discountRuleNames ?? []).filter((n) => typeof n === \"string\" && n.length > 0),\n pricePerUnit: minVariant?.physicalProperties?.pricePerUnit?.description ?? raw.physicalProperties?.pricePerUnitRange?.minValue?.description ?? null,\n ribbon: ribbons[0] ?? null,\n ribbons,\n minPriceVariantId: minVariant ? id(minVariant) || minVariant.variantId || null : null,\n availability,\n preorder: isPreorderProduct(raw),\n hasSubscriptions,\n imageUrl: mainUrl,\n hoverImageUrl: hover[0] ?? \"\",\n optionsSummary: options\n .map((o) => {\n const n = (o.choicesSettings?.choices ?? []).filter((c) => c.visible !== false).length;\n return `${n} ${String(o.name ?? \"\").toLowerCase()}${n === 1 ? \"\" : \"s\"}`;\n })\n .join(\" · \"),\n swatches: options\n .filter((o) => o.optionRenderType === \"SWATCH_CHOICES\" || o.optionRenderType === \"COLOR_CHOICES\")\n .flatMap((o) => (o.choicesSettings?.choices ?? []))\n .filter((c) => c.visible !== false && c.colorCode)\n .map((c) => String(c.colorCode)),\n // Addable in one click: no options, plainly in stock, and no plan to choose.\n quickAddable: options.length === 0 && availability === \"IN_STOCK\" && !hasSubscriptions,\n };\n}\n/**\n * A choice's linked image. With PRODUCT_CHOICES_MEDIA_REFERENCES the choice carries\n * `media.items[].mediaId` pointing into the product gallery; without it Wix returns the same link\n * as the deprecated `linkedMedia` with the media object inline. Both read forms are handled.\n */\nfunction choiceImage(c, gallery, imgSrc) {\n if (!imgSrc)\n return null;\n const ref = (c.media?.items ?? [])[0]?.mediaId;\n const linked = (c.linkedMedia ?? [])[0];\n const m = ref ? gallery.get(String(ref)) : linked ? (linked.image ?? linked) : null;\n return m ? imgSrc(m, 1200, 1200) || null : null;\n}\nexport function toOptions(raw, imgSrc) {\n // the gallery by item id — `id` over REST, `_id` through the SDK (both feed this mapper)\n const gallery = new Map();\n for (const m of [raw.media?.main, ...(raw.media?.itemsInfo?.items ?? [])]) {\n const key = m?._id ?? m?.id;\n if (key)\n gallery.set(String(key), m.image ?? m);\n }\n return (raw.options ?? []).map((o) => ({\n id: id(o) || o.name || \"\",\n key: o.key ?? o.name ?? \"\",\n name: o.name ?? \"\",\n isColor: o.optionRenderType === \"SWATCH_CHOICES\" || o.optionRenderType === \"COLOR_CHOICES\",\n choices: (o.choicesSettings?.choices ?? [])\n .filter((c) => c.visible !== false) // a retired choice the merchant no longer sells\n .map((c) => ({\n choiceId: c.choiceId ?? \"\",\n key: c.key ?? c.name ?? \"\",\n name: c.name ?? \"\",\n colorCode: c.colorCode ?? null,\n inStock: c.inStock !== false,\n imageUrl: choiceImage(c, gallery, imgSrc),\n })),\n }));\n}\n// Mandatory unless the API says `mandatory: false` — an omitted flag is REQUIRED on Wix's own\n// storefront. A free-text modifier is keyed by `freeTextSettings.key` (what `customTextFields`\n// takes); a choice modifier by its own `key` (what `options` takes).\nexport function toModifiers(raw) {\n return (raw.modifiers ?? []).map((m) => {\n const isText = m.modifierRenderType === \"FREE_TEXT\";\n const ft = m.freeTextSettings ?? {};\n const charCount = (v) => (typeof v === \"number\" && v > 0 ? v : null);\n return {\n id: id(m) || m.key || m.name || \"\",\n key: isText ? ft.key ?? m.key ?? m.name ?? \"\" : m.key ?? m.name ?? \"\",\n name: m.name ?? \"\",\n mandatory: m.mandatory !== false,\n type: isText ? \"text\" : \"choices\",\n title: (isText ? ft.title : undefined) || m.name || \"\",\n maxChars: isText ? charCount(ft.maxCharCount) : null,\n minChars: isText ? charCount(ft.minCharCount) : null,\n choices: (m.choicesSettings?.choices ?? []).map((c) => ({ choiceId: c.choiceId ?? \"\", key: c.key ?? c.name ?? \"\", name: c.name ?? \"\" })),\n };\n });\n}\nconst FREQUENCY_WORD = { DAY: \"day\", WEEK: \"week\", MONTH: \"month\", YEAR: \"year\" };\n/** \"every 2 months · 6 payments\" / \"every month · until cancelled\" from a plan's settings. */\nexport function planTerms(frequency, interval, billingCycles) {\n const parts = [];\n const u = FREQUENCY_WORD[String(frequency ?? \"\")];\n if (u)\n parts.push(`every ${interval > 1 ? `${interval} ${u}s` : u}`);\n parts.push(billingCycles ? `${billingCycles} payments` : \"until cancelled\");\n return parts.join(\" · \");\n}\n/** The product's visible recurring plans; a plan without `billingCycles` auto-renews. */\nexport function toSubscriptionPlans(raw) {\n return visibleSubscriptions(raw).map((s) => {\n const frequency = [\"DAY\", \"WEEK\", \"MONTH\", \"YEAR\"].find((f) => f === s.frequency) ?? null;\n const interval = typeof s.interval === \"number\" && s.interval > 0 ? s.interval : 1;\n const billingCycles = s.autoRenewal === true ? null : typeof s.billingCycles === \"number\" && s.billingCycles > 0 ? s.billingCycles : null;\n return { id: id(s), name: s.title ?? \"\", description: s.description ?? \"\", frequency, interval, billingCycles, terms: planTerms(frequency, interval, billingCycles) };\n });\n}\n/**\n * Variants keyed by optionId → choiceId (names kept for display). Buyable when in stock OR\n * pre-orderable. `quantity`/`preorderMessage` stay null here — the Inventory Items read fills them\n * (mergeInventory). `variant.media` is read-only on the API: Wix derives it from the media linked\n * to the variant's choice (a product with several options derives it only when the choices\n * agree). Mapped to a URL so a PDP can show the selected variant's picture without knowing how\n * Wix derives it.\n */\nexport function toVariants(raw, imgSrc) {\n return (raw.variantsInfo?.variants ?? [])\n .filter((v) => v.visible !== false)\n .map((v) => {\n const choices = {};\n const choiceIds = {};\n for (const c of (v.choices ?? [])) {\n const on = c.optionChoiceNames?.optionName, cn = c.optionChoiceNames?.choiceName;\n if (on && cn)\n choices[on] = cn;\n const oi = c.optionChoiceIds?.optionId, ci = c.optionChoiceIds?.choiceId;\n if (oi && ci)\n choiceIds[oi] = ci;\n }\n const { current, original } = sellingPrice(v.price);\n const vm = v.media ? (v.media.image ?? v.media) : null;\n const subscriptionPrices = {};\n for (const sp of (v.subscriptionPricesInfo?.subscriptionPrices ?? [])) {\n const formatted = sp.priceAfterDiscount?.formattedAmount ?? sp.price?.formattedAmount;\n if (sp.subscriptionId && formatted)\n subscriptionPrices[sp.subscriptionId] = formatted;\n }\n const amount = Number(current?.amount);\n return {\n variantId: id(v),\n choiceIds,\n choices,\n price: current?.formattedAmount ?? \"\",\n priceAmount: Number.isFinite(amount) ? amount : 0,\n compareAtPrice: strike(original, current),\n pricePerUnit: v.physicalProperties?.pricePerUnit?.description ?? null,\n sku: v.sku ?? null,\n subscriptionPrices,\n inStock: v.inventoryStatus?.inStock !== false,\n preorderEnabled: v.inventoryStatus?.preorderEnabled === true,\n quantity: null,\n preorderMessage: null,\n imageUrl: vm ? imgSrc(vm, 1200, 1200) || null : null,\n };\n });\n}\n/** The product's category path and direct categories, from BREADCRUMBS_INFO / DIRECT_CATEGORIES_INFO. */\nexport function toBreadcrumbs(info) {\n return (info?.breadcrumbs ?? [])\n .filter((b) => b.categoryId)\n .map((b) => ({ id: b.categoryId, name: b.categoryName ?? \"\", slug: b.categorySlug ?? \"\" }));\n}\nexport function toDetail(raw, imgSrc, mediaKey) {\n const summary = toSummary(raw, imgSrc, mediaKey);\n const variants = toVariants(raw, imgSrc);\n // The exact range from the variants' SELLING prices (discounts applied) — the summary's range is\n // pre-discount and had to say \"From\" when the cheapest variant was discounted.\n let price = summary.price, maxPrice = summary.maxPrice, compareAtPrice = summary.compareAtPrice;\n if (variants.length) {\n const lo = variants.reduce((a, v) => (v.priceAmount < a.priceAmount ? v : a));\n const hi = variants.reduce((a, v) => (v.priceAmount > a.priceAmount ? v : a));\n price = lo.price || price;\n maxPrice = hi.price || maxPrice;\n compareAtPrice = price !== maxPrice ? null : lo.compareAtPrice;\n }\n return {\n ...summary,\n price,\n maxPrice,\n fromPrice: false,\n compareAtPrice,\n descriptionHtml: raw.plainDescription ?? \"\", // an HTML string despite the name — render as HTML\n infoSections: (raw.infoSections ?? [])\n .map((s) => ({ title: s.title ?? \"\", html: s.plainDescription ?? \"\" }))\n .filter((s) => s.title || s.html),\n // De-duplicated on media identity, then resolved once at one size.\n gallery: mediaEntries(raw, mediaKey).map((m) => imgSrc(m, 1200, 1200)).filter(Boolean),\n breadcrumbs: toBreadcrumbs(raw.breadcrumbsInfo),\n categoryIds: (raw.directCategoriesInfo?.categories ?? []).map((c) => id(c)).filter(Boolean),\n options: toOptions(raw, imgSrc),\n modifiers: toModifiers(raw),\n variants,\n subscriptions: toSubscriptionPlans(raw),\n allowOneTimePurchase: raw.subscriptionDetails?.allowOneTimePurchases === true,\n };\n}\nexport function toCategory(raw, imgSrc) {\n const parent = raw.parentCategory;\n return {\n id: id(raw),\n slug: raw.slug ?? \"\",\n name: raw.name ?? \"\",\n description: raw.description ?? \"\",\n imageUrl: imgSrc(raw.image, 1200, 1200),\n parentId: parent ? id(parent) || null : null,\n index: typeof parent?.index === \"number\" ? parent.index : 0,\n productCount: typeof raw.itemCounter === \"number\" ? raw.itemCounter : null,\n breadcrumbs: toBreadcrumbs(raw.breadcrumbsInfo),\n };\n}\n// ---- inventory --------------------------------------------------------------------------------------\n/** Inventory Items → per-variant record: a PREORDER item's remaining allowance is `preorderInfo.quantity`. */\nexport function toInventory(items) {\n const out = {};\n for (const it of items) {\n if (!it?.variantId)\n continue;\n const status = it.availabilityStatus === \"IN_STOCK\" || it.availabilityStatus === \"PREORDER\" ? it.availabilityStatus : \"OUT_OF_STOCK\";\n const tracked = it.trackQuantity === true;\n out[it.variantId] = {\n status,\n quantity: tracked && typeof it.quantity === \"number\" ? Math.max(0, it.quantity) : null,\n preorderQuantity: tracked && typeof it.preorderInfo?.quantity === \"number\" ? Math.max(0, it.preorderInfo.quantity) : null,\n preorderMessage: it.preorderInfo?.message || null,\n };\n }\n return out;\n}\n/**\n * The variants with the default location's live inventory: status → inStock/preorderEnabled, the\n * remaining units (`quantity`: stock, or the pre-order allowance while pre-ordering), the pre-order\n * message. A variant without a record keeps its catalog flags.\n */\nexport function mergeInventory(variants, inventory) {\n return variants.map((v) => {\n const inv = inventory[v.variantId];\n if (!inv)\n return v;\n const preorder = inv.status === \"PREORDER\";\n return {\n ...v,\n inStock: inv.status === \"IN_STOCK\",\n preorderEnabled: preorder,\n quantity: preorder ? inv.preorderQuantity : inv.quantity,\n preorderMessage: preorder ? inv.preorderMessage : null,\n };\n });\n}\n// ---- selection rules --------------------------------------------------------------------------------\n/**\n * Resolve the variant for the buyer's selections (optionId → choiceId). A product with no options\n * resolves to its single variant; with options every option must be selected and match. null\n * while incomplete. Selections start EMPTY — never pre-pick a first choice.\n */\nexport function resolveVariant(detail, selections) {\n if (detail.options.length === 0)\n return detail.variants[0] ?? null;\n if (!detail.options.every((o) => selections[o.id]))\n return null;\n return detail.variants.find((v) => detail.options.every((o) => v.choiceIds[o.id] === selections[o.id])) ?? null;\n}\n/**\n * Per-choice availability AGAINST the current selection: optionId → choiceId → { inStock, exists }.\n * With nothing picked, a choice's catalog flag. Otherwise each choice is judged as if picked on\n * top of the other selections: `exists` when some variant answers to that combination, `inStock`\n * when one of those is buyable (in stock or pre-orderable). Red picked → XL greys out when Red/XL\n * is sold out, but stays clickable elsewhere.\n */\nexport function choiceAvailability(detail, selections) {\n const picked = Object.entries(selections).filter(([oid, cid]) => oid && cid);\n const out = {};\n for (const o of detail.options) {\n out[o.id] = {};\n for (const c of o.choices) {\n if (!picked.length) {\n out[o.id][c.choiceId] = { inStock: c.inStock, exists: true };\n continue;\n }\n const candidate = [...picked.filter(([oid]) => oid !== o.id), [o.id, c.choiceId]];\n let exists = false, inStock = false;\n for (const v of detail.variants) {\n if (!candidate.every(([oid, cid]) => v.choiceIds[oid] === cid))\n continue;\n exists = true;\n if (v.inStock || v.preorderEnabled) {\n inStock = true;\n break;\n }\n }\n out[o.id][c.choiceId] = { inStock, exists };\n }\n }\n return out;\n}\n/** Wix's storefront caps the stepper at 99999 when stock isn't counted. */\nexport const MAX_QUANTITY = 99999;\n/** The most a buyer may add of a variant: its remaining units when counted (none left → the cap; the add is blocked anyway), else the cap. */\nexport function maxPurchasable(variant) {\n return variant?.quantity || MAX_QUANTITY;\n}\n",
18 "js/wix/catalog.js": "// Declarations of catalog.ts: what this file exports, their arguments and what they return.\n// import { CATALOG_SORTS, choiceAvailability, resolveVariant, sellingPrice, type CatalogSearchOptions, type CatalogSort, type FacetSelection } from \"./catalog-core.js\";\n// import type { Category, Facet, FacetData, ProductDetail, ProductSummary, VariantInventory } from \"./storefront-types.js\";\n// export { CATALOG_SORTS, choiceAvailability, resolveVariant, sellingPrice, type CatalogSearchOptions, type CatalogSort, type FacetSelection };\n// export declare function searchCatalog(o?: CatalogSearchOptions): Promise<{\n// products: ProductSummary[];\n// nextCursor: string | null;\n// hasMore: boolean;\n// total: number | null;\n// }>;\n// export declare function fetchFacets(scope?: {\n// categoryId?: string | null;\n// }): Promise<Facet[]>;\n// export declare function fetchFacetData({ categoryId }?: {\n// categoryId?: string | null;\n// }): Promise<FacetData>;\n// export declare function fetchProducts({ limit }?: {\n// limit?: number | undefined;\n// }): Promise<ProductSummary[]>;\n// export declare function fetchProductsByCategory(categoryId: string, { limit }?: {\n// limit?: number | undefined;\n// }): Promise<ProductSummary[]>;\n// export declare function fetchProductBySlug(slug: string): Promise<ProductDetail | null>;\n// export declare function fetchAllProductsCategoryId(): Promise<string | null>;\n// export declare function fetchCategories(): Promise<Category[]>;\n// export declare function fetchCategoryBySlug(slug: string): Promise<Category | null>;\n// export declare function fetchDefaultLocationId(): Promise<string | null>;\n// export declare function fetchInventory(productId: string): Promise<Record<string, VariantInventory>>;\n// export declare function fetchBackInStockEnabled(): Promise<boolean>;\n// export declare function requestBackInStock(productId: string, variantId: string | null, email: string, item: {\n// name: string;\n// price: string;\n// imageUrl?: string;\n// }): Promise<\"created\" | \"already-subscribed\">;\n// Catalog reads over REST — the twin of app/wix/storefront/catalog.ts. Same exports, same DTOs; the\n// rules and mappers come from catalog-core (the SAME file the SDK transport uses, deployed flat next\n// to this one by deploy.mjs --stack static), so this file is only the transport: one fetch with a\n// literal body per function. Every call here is safe from a browser with a visitor token.\n// Porting: keep the paths, keep the bodies, port the core once.\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/search-products.md\n// docs: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/categories/get-category-by-slug.md\nimport { WixApiError, wixRequest } from \"./client.js\";\nimport { imgSrc, mediaKey } from \"./media.js\";\nimport { CATALOG_SORTS, DETAIL_FIELDS, LIST_FIELDS, STORES_TREE, choiceAvailability, facetCustomizationIds, facetSearchBody, pageInfo, readFacetAggregates, resolveVariant, searchQuery, sellingPrice, toCategory, toDetail, toFacetData, toInventory, toSummary, } from \"./catalog-core.js\";\nimport { WIX_STORES_APP_ID } from \"./cart-core.js\";\nexport { CATALOG_SORTS, choiceAvailability, resolveVariant, sellingPrice };\nconst notFound = (e) => e instanceof WixApiError && e.status === 404;\n/**\n * The gallery query: sort, filter, facets, search, and cursor paging, all applied by Wix across the\n * whole catalog — never on a page already in hand. `total` is issued alongside the first page only;\n * `hasMore` is the API's own `hasNext`.\n * POST /stores/v3/products/search { fields, search: { filter, sort, search, cursorPaging } }\n * POST /stores/v3/products/count { filter }\n */\nexport async function searchCatalog(o = {}) {\n const search = searchQuery(o);\n const [res, count] = await Promise.all([\n wixRequest(\"/stores/v3/products/search\", { body: { fields: LIST_FIELDS, search } }),\n // the count is a nicety — the page is still valid without it\n !o.cursor ? wixRequest(\"/stores/v3/products/count\", { body: { filter: search.filter } }).catch(() => null) : Promise.resolve(null),\n ]);\n return {\n products: (res?.products ?? []).map((p) => toSummary(p, imgSrc, mediaKey)),\n ...pageInfo(res),\n total: typeof count?.count === \"number\" ? count.count : null,\n };\n}\nexport async function fetchFacets(scope = {}) {\n return (await fetchFacetData(scope)).facets;\n}\n/**\n * Facets and price bounds of a scope: ONE aggregations-only search (cursorPaging.limit 0 — no\n * products, whatever the catalog size), then the named customizations for names, colors, order.\n * Non-fatal: empty on failure.\n * POST /stores/v3/products/search { search: { filter, cursorPaging: { limit: 0 }, aggregations } }\n * POST /stores/v3/customizations/query { query: { filter: { id: { $in }, customizationRenderType: { $ne: \"FREE_TEXT\" } }, cursorPaging: { limit: 100 } } }\n */\nexport async function fetchFacetData({ categoryId } = {}) {\n try {\n const res = await wixRequest(\"/stores/v3/products/search\", { body: { search: facetSearchBody(categoryId) } });\n const agg = readFacetAggregates(res);\n return toFacetData(agg, await fetchCustomizations(facetCustomizationIds(agg)));\n }\n catch {\n return { facets: [], priceRange: null };\n }\n}\nasync function fetchCustomizations(ids) {\n if (!ids.length)\n return [];\n const batches = [];\n for (let i = 0; i < ids.length; i += 100)\n batches.push(ids.slice(i, i + 100));\n const pages = await Promise.all(batches.map((batch) => wixRequest(\"/stores/v3/customizations/query\", {\n body: { query: { filter: { id: { $in: batch }, customizationRenderType: { $ne: \"FREE_TEXT\" } }, cursorPaging: { limit: 100 } } },\n })));\n return pages.flatMap((p) => (p?.customizations ?? []));\n}\nexport async function fetchProducts({ limit = 24 } = {}) {\n return (await searchCatalog({ limit })).products;\n}\nexport async function fetchProductsByCategory(categoryId, { limit = 24 } = {}) {\n return (await searchCatalog({ limit, categoryId })).products;\n}\n/**\n * One complete product for the PDP; null when the slug doesn't resolve — a real 404, never a\n * fallback to another product. GET /stores/v3/products/slug/{slug}?fields=…&fields=…\n */\nexport async function fetchProductBySlug(slug) {\n try {\n const res = await wixRequest(`/stores/v3/products/slug/${encodeURIComponent(decodeSlug(slug))}`, { method: \"GET\", query: { fields: DETAIL_FIELDS } });\n return res?.product ? toDetail(res.product, imgSrc, mediaKey) : null;\n }\n catch (e) {\n if (notFound(e))\n return null;\n throw e;\n }\n}\nfunction decodeSlug(slug) {\n try {\n return decodeURIComponent(slug);\n }\n catch {\n return slug;\n }\n}\n/**\n * The id of Wix's auto-created \"All Products\" system category — excluded from navigation by id.\n * null when the read fails (the slug heuristic then stands in). GET /stores/v3/all-products-category\n */\nexport async function fetchAllProductsCategoryId() {\n try {\n const res = await wixRequest(\"/stores/v3/all-products-category\", { method: \"GET\" });\n return res?.categoryId ?? null;\n }\n catch {\n return null;\n }\n}\n/**\n * The live VISIBLE category tree for navigation — never a seeded list; every page of it; the\n * \"All Products\" system category dropped by id. The API returns only visible categories unless\n * asked otherwise; the `visible` filter also keeps the query non-empty (an empty filter is\n * rejected on the visitor path). Non-fatal: [] on failure.\n * POST /categories/v1/categories/query { treeReference, fields, query: { filter: { visible: true }, cursorPaging: { limit, cursor } } }\n */\nexport async function fetchCategories() {\n try {\n const [allProductsId, items] = await Promise.all([fetchAllProductsCategoryId(), readCategoryPages()]);\n return items\n .filter((c) => c.visible !== false)\n .map((c) => toCategory(c, imgSrc))\n .filter((c) => (allProductsId ? c.id !== allProductsId : c.slug !== \"all-products\"));\n }\n catch {\n return [];\n }\n}\nasync function readCategoryPages() {\n const out = [];\n let cursor = null;\n for (let i = 0; i < 20; i++) {\n const res = await wixRequest(\"/categories/v1/categories/query\", {\n body: {\n treeReference: STORES_TREE,\n fields: [\"DESCRIPTION\"],\n query: cursor ? { cursorPaging: { limit: 100, cursor } } : { filter: { visible: true }, cursorPaging: { limit: 100 } },\n },\n });\n out.push(...(res?.categories ?? []));\n cursor = res?.pagingMetadata?.hasNext ? res?.pagingMetadata?.cursors?.next ?? null : null;\n if (!cursor)\n break;\n }\n return out;\n}\n/**\n * One category by URL slug (its id feeds searchCatalog; name, description, image, breadcrumbs head\n * the page). The response is WRAPPED: { category }. null when missing or hidden → a real 404.\n * GET /categories/v1/categories/slug/{slug}?treeReference.appNamespace=@wix/stores&fields=BREADCRUMBS_INFO&fields=DESCRIPTION\n */\nexport async function fetchCategoryBySlug(slug) {\n try {\n const res = await wixRequest(`/categories/v1/categories/slug/${encodeURIComponent(decodeSlug(slug))}`, {\n method: \"GET\",\n query: { \"treeReference.appNamespace\": \"@wix/stores\", fields: [\"BREADCRUMBS_INFO\", \"DESCRIPTION\"] },\n });\n const raw = res?.category;\n return raw && raw.visible !== false ? toCategory(raw, imgSrc) : null;\n }\n catch (e) {\n if (notFound(e))\n return null;\n throw e;\n }\n}\n// The default Stores location — inventory is per location. Read once per session.\n// POST /stores/v3/locations/query { query: { filter: { defaultLocation: true } } }\nlet defaultLocation = null;\nexport function fetchDefaultLocationId() {\n defaultLocation ??= wixRequest(\"/stores/v3/locations/query\", { body: { query: { filter: { defaultLocation: true } } } })\n .then((r) => (r?.storesLocations ?? [])[0]?.id ?? null)\n .catch(() => null);\n return defaultLocation;\n}\n/**\n * Live inventory of a product's variants at the default location, keyed by variantId; {} on\n * failure. Merge with mergeInventory() from ./catalog-core.\n * POST /stores/v3/inventory-items/query { query: { filter: { productId, locationId }, cursorPaging: { limit: 100 } } }\n */\nexport async function fetchInventory(productId) {\n try {\n const locationId = await fetchDefaultLocationId();\n const items = [];\n let cursor = null;\n for (let i = 0; i < 10; i++) {\n const res = await wixRequest(\"/stores/v3/inventory-items/query\", {\n body: { query: cursor ? { cursorPaging: { limit: 100, cursor } } : { filter: { productId, ...(locationId ? { locationId } : {}) }, cursorPaging: { limit: 100 } } },\n });\n items.push(...(res?.inventoryItems ?? []));\n cursor = res?.pagingMetadata?.hasNext ? res?.pagingMetadata?.cursors?.next ?? null : null;\n if (!cursor)\n break;\n }\n return toInventory(items);\n }\n catch {\n return {};\n }\n}\n// Back in Stock (eCom). The public paths are the ones the generated SDK resolves for\n// www.wixapis.com: its \"/back-in-stock-service\" prefix in front of the service paths, and\n// GetSettings as a PUT with an empty body — kept exactly, not guessed.\nconst BACK_IN_STOCK = \"/back-in-stock-service/v1/back-in-stock-notification-requests\";\n// Whether the merchant collects \"notify me\" requests for Stores items. Read once; false on failure.\n// PUT /back-in-stock-service/v1/back-in-stock-notification-requests/settings {}\nlet backInStockEnabled = null;\nexport function fetchBackInStockEnabled() {\n backInStockEnabled ??= wixRequest(`${BACK_IN_STOCK}/settings`, { method: \"PUT\", body: {} })\n .then((r) => (r?.settings?.collectionStates ?? []).some((s) => s.appId === WIX_STORES_APP_ID && s.collectingRequests === true))\n .catch(() => false);\n return backInStockEnabled;\n}\n/**\n * Ask Wix to email the buyer when the variant is back in stock. `price` is the variant's plain\n * amount (a decimal string). \"already-subscribed\" when that email already asked for this item.\n * POST /back-in-stock-service/v1/back-in-stock-notification-requests { request: { catalogReference, email }, itemDetails: { name, price, image } }\n */\nexport async function requestBackInStock(productId, variantId, email, item) {\n try {\n await wixRequest(BACK_IN_STOCK, {\n body: {\n request: { catalogReference: { catalogItemId: productId, appId: WIX_STORES_APP_ID, ...(variantId ? { options: { variantId } } : {}) }, email },\n itemDetails: { name: item.name, price: item.price, ...(item.imageUrl ? { image: item.imageUrl } : {}) },\n },\n });\n return \"created\";\n }\n catch (e) {\n if (e instanceof WixApiError && e.code === \"BACK_IN_STOCK_NOTIFICATION_REQUEST_ALREADY_EXISTS\")\n return \"already-subscribed\";\n throw e;\n }\n}\n",
19 "js/wix/product-detail-store.js": "// Declarations of product-detail-store.ts: what this file exports, their arguments and what they return.\n// import type { OptionChoice, ProductDetail, ProductOption, ProductVariant, SubscriptionPlan } from \"./storefront-types.js\";\n// export interface ProductDetailStoreOptions {\n// initial?: ProductDetail | null;\n// slug?: string;\n// }\n// export declare const ONE_TIME_PLAN = \"one-time\";\n// export interface OptionGroupView extends ProductOption {\n// choices: (OptionChoice & {\n// selected: boolean;\n// exists: boolean;\n// })[];\n// }\n// export interface PlanView extends SubscriptionPlan {\n// price: string;\n// selected: boolean;\n// }\n// export interface ProductDetailState {\n// product: ProductDetail | null;\n// notFound: boolean;\n// optionGroups: OptionGroupView[];\n// modifierValues: Record<string, string>;\n// variant: ProductVariant | null;\n// imageUrl: string | null;\n// price: string;\n// compareAtPrice: string | null;\n// pricePerUnit: string | null;\n// plans: PlanView[];\n// subscriptionPlanId: string | null;\n// isPreorder: boolean;\n// preorderMessage: string | null;\n// remaining: number | null;\n// maxQuantity: number;\n// canAdd: boolean;\n// blockedReason: string | null;\n// quantity: number;\n// adding: boolean;\n// error: string | null;\n// canNotify: boolean;\n// notifying: boolean;\n// notifyResult: \"created\" | \"already-subscribed\" | null;\n// notifyError: string | null;\n// }\n// export interface ProductDetailStore {\n// getState(): ProductDetailState;\n// subscribe(listener: (state: ProductDetailState) => void): () => void;\n// start(): void;\n// stop(): void;\n// selectOption(optionId: string, choiceId: string): void;\n// setModifier(key: string, value: string): void;\n// setQuantity(n: number): void;\n// selectPlan(planId: string): void;\n// add(): Promise<void>;\n// notify(email: string): Promise<\"created\" | \"already-subscribed\">;\n// }\n// export declare function createProductDetailStore({ initial, slug }: ProductDetailStoreOptions): ProductDetailStore;\n// Product detail as a framework-free store — the logic behind useProductDetail, usable from React\n// (useProductDetail wraps it), from a static page's PDP and quick-add picker, from Vue/Svelte, or as\n// the specification for a port. Option selection → variant resolution → add-to-cart, plus modifier\n// inputs, the plan picker for a subscription product, the live inventory (stock left, purchase\n// cap, pre-order message) and \"notify me\" for a sold-out item. This is where the \"adds variants[0]\n// regardless of the buyer's choice\" class of bugs comes from — always drive a purchase surface\n// through this store, never resolve variants by hand.\n//\n// Selections start EMPTY (a buyer who never noticed a pre-picked default buys the wrong colour).\n// Pass the server-fetched ProductDetail as `initial` (SSR) or a `slug` (the store fetches on start).\n// One store per product surface: createProductDetailStore(), not a singleton.\nimport { fetchBackInStockEnabled, fetchInventory, fetchProductBySlug, requestBackInStock, resolveVariant } from \"./catalog.js\";\nimport { choiceAvailability, maxPurchasable, mergeInventory } from \"./catalog-core.js\";\nimport { addLine } from \"./cart-store.js\";\n/** The buyer's choice for \"buy once\" on a product that also sells plans. */\nexport const ONE_TIME_PLAN = \"one-time\";\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nexport function createProductDetailStore({ initial, slug }) {\n let product = initial ?? null;\n let notFound = false;\n let selections = {}; // optionId -> choiceId\n let lastPicked = null; // the option picked most recently — its choice's image wins\n let modifierValues = {};\n let subscriptionPlanId = null;\n let quantity = 1;\n let adding = false;\n let error = null;\n let notifyEnabled = false;\n let notifying = false;\n let notifyResult = null;\n let notifyError = null;\n let started = false;\n let enriched = null; // product id whose inventory was loaded\n const listeners = new Set();\n let snapshot = null;\n const emit = () => { snapshot = null; for (const fn of listeners)\n fn(); };\n function getState() {\n if (snapshot)\n return snapshot;\n const variant = product ? resolveVariant(product, selections) : null;\n const availability = product ? choiceAvailability(product, selections) : {};\n const optionGroups = (product?.options ?? []).map((o) => ({\n ...o,\n choices: o.choices.map((c) => {\n const a = availability[o.id]?.[c.choiceId];\n return { ...c, selected: selections[o.id] === c.choiceId, inStock: a ? a.inStock : c.inStock, exists: a ? a.exists : true };\n }),\n }));\n const pickedImage = (optionId) => {\n const o = product?.options.find((x) => x.id === optionId);\n return o?.choices.find((c) => c.choiceId === selections[optionId])?.imageUrl ?? null;\n };\n const pickedImages = (product?.options ?? []).map((o) => pickedImage(o.id)).filter((u) => !!u);\n const imageUrl = (lastPicked ? pickedImage(lastPicked) : null) ?? pickedImages[pickedImages.length - 1] ?? variant?.imageUrl ?? null;\n const missingOptions = (product?.options ?? []).filter((o) => !selections[o.id]).map((o) => o.name);\n const selectionComplete = missingOptions.length === 0;\n const missingModifier = (product?.modifiers ?? []).find((m) => m.mandatory && (modifierValues[m.key] ?? \"\").trim().length === 0);\n const shortText = (product?.modifiers ?? []).find((m) => m.type === \"text\" && m.minChars && (modifierValues[m.key] ?? \"\").length > 0 && (modifierValues[m.key] ?? \"\").length < m.minChars);\n const plans = product?.subscriptions ?? [];\n const plan = subscriptionPlanId && subscriptionPlanId !== ONE_TIME_PLAN ? plans.find((p) => p.id === subscriptionPlanId) ?? null : null;\n const planMissing = plans.length > 0 && (!subscriptionPlanId || (subscriptionPlanId === ONE_TIME_PLAN && !product?.allowOneTimePurchase) || (subscriptionPlanId !== ONE_TIME_PLAN && !plan));\n // In stock OR pre-orderable counts as buyable; a preorder add carries preOrderRequested.\n const isPreorder = !!variant && !variant.inStock && variant.preorderEnabled;\n const available = !!variant && (variant.inStock || variant.preorderEnabled);\n const canAdd = !!product && available && !missingModifier && !shortText && !planMissing;\n // Wix's own precedence: unavailable > pre-order > sold out > the inputs still needed.\n const blockedReason = !product\n ? null\n : !selectionComplete ? `Choose ${missingOptions.join(\" and \")}`\n : !variant ? \"This combination isn't available\"\n : !available ? \"Out of stock\"\n : missingModifier ? `Add ${missingModifier.title || missingModifier.name}`\n : shortText ? `${shortText.title || shortText.name} needs at least ${shortText.minChars} characters`\n : planMissing ? \"Choose a plan\"\n : null;\n // Before every option is picked, the range — never an empty price, never a lone struck minimum.\n const isRange = !!product && product.price !== product.maxPrice && !!product.maxPrice;\n const rangeDisplay = product ? (product.fromPrice ? `From ${product.price}` : isRange ? `${product.price} – ${product.maxPrice}` : product.price) : \"\";\n const planPrice = plan && variant ? variant.subscriptionPrices[plan.id] ?? \"\" : \"\";\n snapshot = {\n product, notFound, optionGroups, modifierValues, variant, imageUrl,\n price: planPrice || variant?.price || rangeDisplay,\n // a plan price is its own thing — never strike the one-time price beside it\n compareAtPrice: plan ? null : variant ? variant.compareAtPrice : isRange ? null : (product?.compareAtPrice ?? null),\n pricePerUnit: variant?.pricePerUnit ?? product?.pricePerUnit ?? null,\n plans: plans.map((p) => ({ ...p, price: variant?.subscriptionPrices[p.id] ?? \"\", selected: subscriptionPlanId === p.id })),\n subscriptionPlanId,\n isPreorder,\n preorderMessage: isPreorder ? variant?.preorderMessage ?? null : null,\n remaining: variant?.quantity ?? null,\n maxQuantity: maxPurchasable(variant),\n canAdd, blockedReason, quantity, adding, error,\n canNotify: !!product && !!variant && !available && notifyEnabled,\n notifying, notifyResult, notifyError,\n };\n return snapshot;\n }\n // The live inventory (stock left, pre-order allowance and message) refines the catalog's flags;\n // \"notify me\" only shows when the merchant collects requests. Both are non-blocking niceties.\n function enrich() {\n if (!product || enriched === product.id)\n return;\n const id = enriched = product.id;\n fetchInventory(id).then((inv) => {\n if (!started || !product || product.id !== id || !Object.keys(inv).length)\n return;\n product = { ...product, variants: mergeInventory(product.variants, inv) };\n emit();\n });\n if (product.variants.some((v) => !v.inStock && !v.preorderEnabled) || product.availability !== \"IN_STOCK\") {\n fetchBackInStockEnabled().then((on) => { if (started && on !== notifyEnabled) {\n notifyEnabled = on;\n emit();\n } });\n }\n }\n return {\n getState,\n subscribe(fn) {\n const listener = () => { try {\n fn(getState());\n }\n catch (e) {\n console.error(\"product-detail-store listener failed\", e);\n } };\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n start() {\n if (started)\n return;\n started = true;\n if (product) {\n enrich();\n return;\n }\n if (!slug)\n return;\n fetchProductBySlug(slug)\n .then((p) => { if (!started)\n return; product = p; notFound = p === null; emit(); enrich(); })\n .catch(() => { if (started) {\n notFound = true;\n emit();\n } });\n },\n stop() { started = false; },\n selectOption(optionId, choiceId) {\n selections = { ...selections, [optionId]: choiceId };\n lastPicked = optionId;\n quantity = 1; // the ceiling belongs to the newly resolved variant\n notifyResult = null;\n notifyError = null;\n emit();\n },\n setModifier(key, value) { modifierValues = { ...modifierValues, [key]: value }; emit(); },\n setQuantity(n) {\n const max = getState().maxQuantity;\n quantity = Math.min(Math.max(1, Math.floor(Number(n) || 1)), max);\n emit();\n },\n selectPlan(planId) { subscriptionPlanId = planId; emit(); },\n async add() {\n const s = getState();\n if (!product || !s.variant)\n return;\n adding = true;\n error = null;\n emit();\n try {\n // The variant id AND every option's key/choice-key pair, as Wix's own storefront sends them.\n const optionChoices = {};\n for (const o of product.options) {\n const choice = o.choices.find((c) => c.choiceId === s.variant.choiceIds[o.id]);\n if (o.key && choice?.key)\n optionChoices[o.key] = choice.key;\n }\n const modifierChoices = {};\n const customTextFields = {};\n for (const m of product.modifiers) {\n const value = modifierValues[m.key];\n if (!value)\n continue;\n if (m.type === \"text\")\n customTextFields[m.key] = value;\n else\n modifierChoices[m.key] = value;\n }\n const plan = subscriptionPlanId && subscriptionPlanId !== ONE_TIME_PLAN ? subscriptionPlanId : undefined;\n await addLine(product.id, s.variant.variantId, quantity, { optionChoices, modifierChoices, customTextFields, preorder: s.isPreorder, subscriptionOptionId: plan });\n }\n catch (e) {\n error = e instanceof Error ? e.message : String(e);\n throw e;\n }\n finally {\n adding = false;\n emit();\n }\n },\n async notify(email) {\n const s = getState();\n if (!product)\n throw new Error(\"No product.\");\n if (!EMAIL.test(email.trim())) {\n notifyError = \"Enter a valid email address.\";\n emit();\n throw new Error(notifyError);\n }\n notifying = true;\n notifyError = null;\n notifyResult = null;\n emit();\n try {\n const result = await requestBackInStock(product.id, s.variant?.variantId ?? null, email.trim(), {\n name: product.name,\n price: String(s.variant?.priceAmount ?? product.variants[0]?.priceAmount ?? 0),\n imageUrl: product.imageUrl || undefined,\n });\n notifyResult = result;\n return result;\n }\n catch (e) {\n notifyError = e instanceof Error && e.message ? e.message : \"Couldn't save your request. Please try again.\";\n throw e;\n }\n finally {\n notifying = false;\n emit();\n }\n },\n };\n}\n",
20 "js/wix/shop-store.js": "// Declarations of shop-store.ts: what this file exports, their arguments and what they return.\n// import { type CatalogSort } from \"./catalog.js\";\n// import type { Category, Facet, PriceRange, ProductSummary } from \"./storefront-types.js\";\n// export declare const SORTS: {\n// readonly featured: {\n// readonly label: \"Default order\";\n// };\n// readonly priceAsc: {\n// readonly label: \"Price: low to high\";\n// };\n// readonly priceHigh: {\n// readonly label: \"Price: high to low\";\n// };\n// readonly name: {\n// readonly label: \"Name: A–Z\";\n// };\n// readonly nameDesc: {\n// readonly label: \"Name: Z–A\";\n// };\n// readonly newest: {\n// readonly label: \"Newest\";\n// };\n// };\n// export interface ShopFilters {\n// minPrice?: number | string;\n// maxPrice?: number | string;\n// inStockOnly?: boolean;\n// search?: string;\n// }\n// export interface ShopStoreOptions {\n// initialProducts?: ProductSummary[];\n// initialCategories?: Category[];\n// initialCategoryId?: string | null;\n// pageSize?: number;\n// syncUrl?: boolean;\n// }\n// export interface ShopState {\n// products: ProductSummary[] | null;\n// total: number | null;\n// categories: Category[];\n// activeCategoryId: string | null;\n// sort: CatalogSort;\n// filters: ShopFilters;\n// facets: Facet[];\n// priceRange: PriceRange | null;\n// selectedChoiceIds: string[];\n// hasActiveFilters: boolean;\n// loading: boolean;\n// error: string | null;\n// hasMore: boolean;\n// loadingMore: boolean;\n// }\n// export interface ShopStore {\n// getState(): ShopState;\n// subscribe(listener: (state: ShopState) => void): () => void;\n// start(): void;\n// stop(): void;\n// setActiveCategoryId(id: string | null): void;\n// setSort(sort: CatalogSort): void;\n// setFilters(filters: ShopFilters): void;\n// toggleChoice(choiceId: string): void;\n// clearFilters(): void;\n// retry(): void;\n// loadMore(): Promise<void>;\n// }\n// export declare function createShopStore({ initialProducts, initialCategories, initialCategoryId, pageSize, syncUrl }?: ShopStoreOptions): ShopStore;\n// The shop listing as a framework-free store — the logic behind useShop, usable from React\n// (useShop wraps it with useSyncExternalStore), from a static page or Vue/Svelte (subscribe and\n// render), or as the specification for a port. Same shape as cart-store.ts: state, actions,\n// subscribe/getState, emit after every change.\n//\n// Category, sort, price/stock/search filters, option facets, and cursor paging are all applied\n// by Wix across the whole catalog (searchCatalog), never to a page already in hand. A changed\n// selection starts a fresh cursor chain; a late response from a superseded query is dropped. The\n// previous page stays on screen (with `loading: true`) while the new one loads — skeletons only\n// when there is nothing to show yet.\n//\n// Facet picks are kept PER FACET: choices inside one facet OR together, facets AND together\n// (\"Red or Blue, and Large\"), exactly as Wix's own storefront filters; a linked choice's children\n// ride along with it.\n//\n// SSR-friendly: seed with `initialProducts`/`initialCategories` and they render at once; `start()`\n// then revalidates the first page to open the cursor chain, replacing the seed without a\n// skeleton flash. `initialCategoryId` scopes the first query (a /category/[slug] page).\n//\n// URL state: sort, filters, facet choices (and a live category switch on /shop) are read from the\n// query string by `start()` and written back with replaceState after every change — a filtered\n// gallery is a link a shopper can share, reload, and step back to. Paging stays out of the URL.\n//\n// One store per mounted listing (a page can hold a shop and a featured rail): createShopStore(),\n// not a singleton. The cart store is a singleton because the cart is one per visitor.\nimport { CATALOG_SORTS, fetchCategories, fetchFacetData, searchCatalog } from \"./catalog.js\";\nimport { expandFacetChoiceIds } from \"./catalog-core.js\";\nexport const SORTS = CATALOG_SORTS;\nconst URL_KEYS = { sort: \"sort\", min: \"min\", max: \"max\", stock: \"stock\", q: \"q\", choice: \"choice\", category: \"category\" };\n// choice=<kind>:<facetId>:<choiceId>; a bare choice id (an older link) counts as an option pick.\nconst encodeChoice = (s) => `${s.kind}:${s.facetId}:${s.choiceId}`;\nfunction decodeChoice(token) {\n const parts = token.split(\":\");\n if (parts.length === 3 && (parts[0] === \"option\" || parts[0] === \"modifier\") && parts[2])\n return { kind: parts[0], facetId: parts[1], choiceId: parts[2] };\n if (parts.length === 1 && parts[0])\n return { kind: \"option\", facetId: \"\", choiceId: parts[0] };\n return null;\n}\nfunction readUrlState() {\n if (typeof window === \"undefined\")\n return null;\n const p = new URLSearchParams(window.location.search);\n const sort = p.get(URL_KEYS.sort);\n const filters = {};\n if (p.get(URL_KEYS.min))\n filters.minPrice = p.get(URL_KEYS.min);\n if (p.get(URL_KEYS.max))\n filters.maxPrice = p.get(URL_KEYS.max);\n if (p.get(URL_KEYS.stock) === \"1\")\n filters.inStockOnly = true;\n if (p.get(URL_KEYS.q))\n filters.search = p.get(URL_KEYS.q);\n return {\n sort: sort && sort in CATALOG_SORTS ? sort : undefined,\n filters,\n selected: p.getAll(URL_KEYS.choice).map(decodeChoice).filter((s) => s !== null),\n categoryId: p.has(URL_KEYS.category) ? p.get(URL_KEYS.category) : undefined,\n };\n}\nfunction writeUrlState(s, initialCategoryId) {\n if (typeof window === \"undefined\")\n return;\n const p = new URLSearchParams(window.location.search);\n for (const k of Object.values(URL_KEYS))\n p.delete(k);\n if (s.sort !== \"featured\")\n p.set(URL_KEYS.sort, s.sort);\n if (s.filters.minPrice != null && s.filters.minPrice !== \"\")\n p.set(URL_KEYS.min, String(s.filters.minPrice));\n if (s.filters.maxPrice != null && s.filters.maxPrice !== \"\")\n p.set(URL_KEYS.max, String(s.filters.maxPrice));\n if (s.filters.inStockOnly)\n p.set(URL_KEYS.stock, \"1\");\n if (s.filters.search?.trim())\n p.set(URL_KEYS.q, s.filters.search.trim());\n for (const c of s.selected)\n p.append(URL_KEYS.choice, encodeChoice(c));\n if (s.activeCategoryId !== initialCategoryId)\n p.set(URL_KEYS.category, s.activeCategoryId ?? \"\");\n const qs = p.toString();\n const next = `${window.location.pathname}${qs ? `?${qs}` : \"\"}${window.location.hash}`;\n if (next !== `${window.location.pathname}${window.location.search}${window.location.hash}`)\n window.history.replaceState(window.history.state, \"\", next);\n}\nexport function createShopStore({ initialProducts, initialCategories, initialCategoryId = null, pageSize = 24, syncUrl = true } = {}) {\n // selection\n let activeCategoryId = initialCategoryId;\n let sort = \"featured\";\n let filters = {};\n let selected = [];\n let attempt = 0;\n // data\n let categories = initialCategories ?? [];\n let facets = [];\n let priceRange = null;\n // the current page; `key` is the selection it answers to (null = the SSR seed, no query yet)\n let pageKey = null;\n let products = initialProducts ?? null;\n let total = null;\n let cursor = null;\n let hasMore = false;\n let error = null;\n let loadingMore = false;\n // control\n let started = false;\n let generation = 0;\n let pendingMore = null;\n const listeners = new Set();\n // The picks grouped per facet, each with its linked children (known once the facets loaded),\n // in a stable order — the query and its key are built from this.\n function facetSelections() {\n const groups = new Map();\n for (const s of selected) {\n const key = `${s.kind}:${s.facetId}`;\n const g = groups.get(key) ?? { id: s.facetId, kind: s.kind, choiceIds: [] };\n for (const cid of expandFacetChoiceIds(facets.find((f) => f.id === s.facetId), [s.choiceId]))\n if (!g.choiceIds.includes(cid))\n g.choiceIds.push(cid);\n groups.set(key, g);\n }\n return [...groups.values()]\n .map((g) => ({ ...g, choiceIds: [...g.choiceIds].sort() }))\n .sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));\n }\n const selectionKey = () => JSON.stringify([pageSize, activeCategoryId, sort, filters.minPrice ?? null, filters.maxPrice ?? null, !!filters.inStockOnly, filters.search ?? \"\", facetSelections(), attempt]);\n let snapshot = null;\n const emit = () => {\n snapshot = null;\n for (const fn of listeners)\n fn();\n };\n function getState() {\n if (snapshot)\n return snapshot;\n const key = selectionKey();\n const current = pageKey === key;\n const hasActiveFilters = selected.length > 0 ||\n !!filters.inStockOnly ||\n (filters.minPrice != null && filters.minPrice !== \"\") ||\n (filters.maxPrice != null && filters.maxPrice !== \"\") ||\n !!(filters.search && filters.search.trim());\n snapshot = {\n // the seed or the previous page stays visible while a query runs; null only before anything loaded\n products,\n total: current ? total : null,\n categories,\n activeCategoryId,\n sort,\n filters,\n facets,\n priceRange,\n selectedChoiceIds: selected.map((s) => s.choiceId),\n hasActiveFilters,\n // Content on screen is not \"loading\" in the skeleton sense — the flag says a query is in flight.\n loading: !current || products === null,\n error: current ? error : null,\n hasMore: current && hasMore && !!cursor,\n loadingMore: current && loadingMore,\n };\n return snapshot;\n }\n // Facets follow the category scope (a Size facet in \"Donuts\" is meaningless).\n let facetScope;\n function loadFacets() {\n if (facetScope === activeCategoryId)\n return;\n facetScope = activeCategoryId;\n const scope = activeCategoryId;\n fetchFacetData({ categoryId: scope }).then((d) => {\n if (!started || scope !== activeCategoryId)\n return;\n facets = d.facets;\n priceRange = d.priceRange;\n // A pick from the URL may have gained linked children now that its facet is known.\n if (selectionKey() !== pageKey)\n query();\n else\n emit();\n });\n }\n // Run the query for the current selection. Keeps the seed (or the previous page) on screen while\n // it runs; skeletons only when there is nothing yet.\n function query() {\n if (!started)\n return;\n const key = selectionKey();\n const id = ++generation;\n pendingMore = null;\n pageKey = key;\n total = null;\n cursor = null;\n hasMore = false;\n error = null;\n loadingMore = false;\n emit();\n const [limit, categoryId, selectedSort, minPrice, maxPrice, inStockOnly, search, selections] = JSON.parse(key);\n searchCatalog({ limit, categoryId, sort: selectedSort, minPrice, maxPrice, inStockOnly, search, facetSelections: selections })\n .then((res) => {\n if (generation !== id || selectionKey() !== key)\n return; // superseded — drop it\n products = res.products;\n total = res.total;\n cursor = res.nextCursor;\n hasMore = res.hasMore;\n error = null;\n emit();\n })\n .catch((e) => {\n if (generation !== id || selectionKey() !== key)\n return;\n products = [];\n total = null;\n cursor = null;\n hasMore = false;\n error = e instanceof Error ? e.message : \"Couldn't load products.\";\n emit();\n });\n }\n function changed() {\n if (syncUrl)\n writeUrlState({ sort, filters, selected, activeCategoryId }, initialCategoryId);\n loadFacets();\n // A new filters object with the same values keeps the page (same selection, same query).\n if (selectionKey() === pageKey)\n emit();\n else\n query();\n }\n return {\n getState,\n subscribe(fn) {\n const listener = () => { try {\n fn(getState());\n }\n catch (e) {\n console.error(\"shop-store listener failed\", e);\n } };\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n start() {\n if (started)\n return;\n started = true;\n // Adopt the URL's selection once (the server rendered the defaults).\n const u = syncUrl ? readUrlState() : null;\n if (u) {\n if (u.sort)\n sort = u.sort;\n if (Object.keys(u.filters).length)\n filters = u.filters;\n if (u.selected.length)\n selected = u.selected;\n if (u.categoryId !== undefined && u.categoryId !== initialCategoryId)\n activeCategoryId = u.categoryId || null;\n }\n if (!initialCategories) {\n fetchCategories().then((list) => { if (started) {\n categories = list;\n emit();\n } }).catch(() => { if (started) {\n categories = [];\n emit();\n } });\n }\n loadFacets();\n query();\n },\n stop() {\n started = false;\n generation++;\n },\n setActiveCategoryId(id) { if (id === activeCategoryId)\n return; activeCategoryId = id; changed(); },\n setSort(next) { if (next === sort)\n return; sort = next; changed(); },\n setFilters(next) { filters = next; changed(); },\n toggleChoice(choiceId) {\n const existing = selected.find((s) => s.choiceId === choiceId);\n if (existing)\n selected = selected.filter((s) => s !== existing);\n else {\n const facet = facets.find((f) => f.choices.some((c) => c.id === choiceId));\n if (!facet)\n return; // not a choice of this scope's facets\n selected = [...selected, { kind: facet.kind, facetId: facet.id, choiceId }];\n }\n changed();\n },\n clearFilters() { filters = {}; selected = []; changed(); },\n retry() { attempt++; changed(); },\n async loadMore() {\n const key = selectionKey();\n if (pageKey !== key || !cursor || !hasMore || pendingMore)\n return;\n const request = { generation, key };\n pendingMore = request; // blocks repeated clicks before a re-render\n const isCurrent = () => generation === request.generation && selectionKey() === key;\n loadingMore = true;\n error = null;\n emit();\n try {\n const res = await searchCatalog({ limit: pageSize, cursor });\n if (isCurrent()) {\n const seen = new Set((products ?? []).map((p) => p.id));\n products = [...(products ?? []), ...res.products.filter((p) => (seen.has(p.id) ? false : (seen.add(p.id), true)))];\n cursor = res.nextCursor;\n hasMore = res.hasMore;\n }\n }\n catch (e) {\n if (isCurrent())\n error = e instanceof Error ? e.message : \"Couldn't load more products.\";\n }\n finally {\n if (pendingMore === request)\n pendingMore = null;\n if (isCurrent()) {\n loadingMore = false;\n emit();\n }\n }\n },\n };\n}\n",
21 "js/wix/storefront-types.js": "// Declarations of storefront-types.ts: what this file exports, their arguments and what they return.\n// export type Availability = \"IN_STOCK\" | \"OUT_OF_STOCK\" | \"PARTIALLY_OUT_OF_STOCK\";\n// /** A product as a listing/grid tile needs it. */\n// export interface ProductSummary {\n// id: string;\n// slug: string;\n// name: string;\n// /**\n// * The price the buyer pays for the cheapest variant, formatted (an automatic discount already\n// * applied). When `price !== maxPrice` the product is a RANGE — render \"price – maxPrice\".\n// */\n// price: string;\n// /** Highest variant price, formatted — differs from `price` when variants are priced differently. */\n// maxPrice: string;\n// /**\n// * The cheapest variant is discounted AND variants are priced differently: the discounted\n// * maximum is unknown until the PDP loads the variants, so render \"From {price}\" (no maxPrice,\n// * no struck price). `maxPrice` equals `price` then.\n// */\n// fromPrice: boolean;\n// /**\n// * Struck \"was\" price, formatted; null when not on sale — and ALWAYS null for a range (a lone\n// * struck minimum beside a range claims a saving that may not apply to the variant picked).\n// */\n// compareAtPrice: string | null;\n// /** Names of the automatic discount rules applying to this product (\"Summer sale\") — render under the price; [] when none. */\n// discountNames: string[];\n// /** Price per unit of the cheapest variant as Wix formats it (\"€2.50 / 100 g\"); null when the product isn't sold by unit. */\n// pricePerUnit: string | null;\n// /** The primary merchant ribbon (\"New\", \"Best Seller\"); null when none. Same as ribbons[0]. */\n// ribbon: string | null;\n// /** EVERY merchant ribbon, primary first (\"New\", \"Sale\") — render all of them, one shared style. */\n// ribbons: string[];\n// /** The cheapest variant's id — what a direct add sends for a product with no options; null when unknown. */\n// minPriceVariantId: string | null;\n// availability: Availability;\n// /** OUT_OF_STOCK but pre-orderable — label \"Pre-order\", not \"Sold out\". */\n// preorder: boolean;\n// /** The product sells as a subscription (recurring plans) — the tile routes to the PDP, where the plan is picked. */\n// hasSubscriptions: boolean;\n// /** Resolved https URL of the main image (\"\" when the product has none). */\n// imageUrl: string;\n// /** Resolved https URL of the second gallery image (\"\" when there is only one). */\n// hoverImageUrl: string;\n// /** e.g. \"2 colors · 3 sizes\"; \"\" for a single-variant product. */\n// optionsSummary: string;\n// /**\n// * Hex colors of a color option's visible choices, catalog order — render as small dots on the\n// * tile (a preview, not a picker: selection happens in QuickAdd or on the PDP). [] when none.\n// */\n// swatches: string[];\n// /** True when the product can be added to the cart with no choices (single variant, in stock, no subscription plans). */\n// quickAddable: boolean;\n// }\n// export interface OptionChoice {\n// choiceId: string;\n// /** Read-only identifier Wix derives from the name — what `catalogReference.options[option.key]` carries. */\n// key: string;\n// name: string;\n// /** Hex color for swatch rendering; null for text choices. */\n// colorCode: string | null;\n// /** At least one variant with this choice is in stock (catalog-level; the PDP store refines it against the current selection). */\n// inStock: boolean;\n// /** The gallery image the merchant linked to this choice (shown when it is picked), resolved to https; null when none. */\n// imageUrl: string | null;\n// }\n// export interface ProductOption {\n// /** The option's customization id — what `selectOption(optionId, choiceId)` and `ProductVariant.choiceIds` use. */\n// id: string;\n// /** Read-only identifier Wix derives from the name — the key inside `catalogReference.options`. */\n// key: string;\n// name: string;\n// /** Render choices as color swatches (true) or text pills (false). */\n// isColor: boolean;\n// choices: OptionChoice[];\n// }\n// export interface ProductModifier {\n// /** The modifier's customization id. */\n// id: string;\n// /**\n// * The key the cart takes: `catalogReference.options[key] = choice.key` for a choice modifier,\n// * `customTextFields[key] = text` for a free-text one (its `freeTextSettings.key`).\n// */\n// key: string;\n// name: string;\n// /** Input required before adding — Wix treats a modifier as mandatory unless it says otherwise. */\n// mandatory: boolean;\n// /** \"choices\" renders pills; \"text\" renders a free-text input. */\n// type: \"choices\" | \"text\";\n// /** The label of a free-text input (Wix's `freeTextSettings.title`); the modifier name for choices. */\n// title: string;\n// /** Free text only: character limits from the merchant; null when unlimited / none. */\n// maxChars: number | null;\n// minChars: number | null;\n// choices: {\n// choiceId: string;\n// key: string;\n// name: string;\n// }[];\n// }\n// /** A recurring plan the product can be bought on. */\n// export interface SubscriptionPlan {\n// id: string;\n// name: string;\n// description: string;\n// /** Payment frequency unit; null when Wix didn't say. */\n// frequency: \"DAY\" | \"WEEK\" | \"MONTH\" | \"YEAR\" | null;\n// /** Every N frequency units (1 = every month). */\n// interval: number;\n// /** Number of payments; null = until cancelled (auto-renewing). */\n// billingCycles: number | null;\n// /** \"every 2 months · 6 payments\" — the terms in words, ready to render. */\n// terms: string;\n// }\n// export interface ProductVariant {\n// variantId: string;\n// /** The option selections this variant answers to, by id: optionId -> choiceId (what resolveVariant matches on). */\n// choiceIds: Record<string, string>;\n// /** The same selections by name: optionName -> choiceName (display only). */\n// choices: Record<string, string>;\n// /** The price the buyer pays, formatted — an automatic discount beats the regular price. */\n// price: string;\n// /** The same selling price as a number in site currency — for ranges, never for display. */\n// priceAmount: number;\n// /** Struck \"was\" price, formatted; null unless it is real and higher than `price`. */\n// compareAtPrice: string | null;\n// /** Price per unit as Wix formats it (\"€2.50 / 100 g\"); null when not sold by unit. */\n// pricePerUnit: string | null;\n// /** Merchant SKU; null when none. */\n// sku: string | null;\n// /** planId -> the formatted price of this variant on that plan (Wix applies the plan's discount to the variant's price). */\n// subscriptionPrices: Record<string, string>;\n// inStock: boolean;\n// /** Out of stock but pre-orderable — still buyable (the add carries preOrderRequested). */\n// preorderEnabled: boolean;\n// /**\n// * Units left to buy: the tracked stock, or the remaining pre-order allowance when pre-ordering.\n// * null when stock isn't counted (made to order) or the inventory hasn't loaded yet.\n// */\n// quantity: number | null;\n// /** The merchant's pre-order note (\"Ships in 3 weeks\"); null when none or not pre-orderable. */\n// preorderMessage: string | null;\n// /**\n// * The image Wix derived for this variant from its choice's linked media, resolved to https; null when none.\n// * Wix derives it at product creation only (and only for single-option products), so a choice image the\n// * merchant added later never lands here: read the choice's own `imageUrl` (the detail store's `imageUrl` does).\n// */\n// imageUrl: string | null;\n// }\n// /** One inventory record per variant at the store's default location (fetchInventory). */\n// export interface VariantInventory {\n// status: \"IN_STOCK\" | \"OUT_OF_STOCK\" | \"PREORDER\";\n// /** Units in stock when counted; null when stock is a yes/no flag. */\n// quantity: number | null;\n// /** Units still pre-orderable when `status` is PREORDER; null otherwise or when uncounted. */\n// preorderQuantity: number | null;\n// preorderMessage: string | null;\n// }\n// /** A category breadcrumb: an ancestor (or, on a product, the path to its main category). */\n// export interface Breadcrumb {\n// id: string;\n// name: string;\n// slug: string;\n// }\n// /** A product as the detail page needs it. */\n// export interface ProductDetail extends ProductSummary {\n// /** Product description as an HTML string — render with innerHTML, not as text. */\n// descriptionHtml: string;\n// /** Merchant info sections (materials, shipping, care…) — title + HTML; render as sections or accordions. */\n// infoSections: {\n// title: string;\n// html: string;\n// }[];\n// /** Every gallery image as a resolved https URL, main image first, de-duplicated. */\n// gallery: string[];\n// /** Path to the product's main category, top-level first (\"Clothing\" > \"T-Shirts\"); [] when it has no main category. */\n// breadcrumbs: Breadcrumb[];\n// /** Ids of the categories the product is directly assigned to. */\n// categoryIds: string[];\n// options: ProductOption[];\n// modifiers: ProductModifier[];\n// variants: ProductVariant[];\n// /** The recurring plans the product is sold on; [] for a one-time-only product. */\n// subscriptions: SubscriptionPlan[];\n// /** With plans present: whether a plain one-time purchase is also offered. */\n// allowOneTimePurchase: boolean;\n// }\n// export interface Category {\n// id: string;\n// slug: string;\n// name: string;\n// /** Plain-text description when the merchant wrote one; \"\" otherwise. */\n// description: string;\n// /** The category's own image, resolved to an https URL; \"\" when none. */\n// imageUrl: string;\n// /** The parent category's id; null for a top-level category. */\n// parentId: string | null;\n// /** Position among its siblings (the merchant's order). */\n// index: number;\n// /** Products directly in this category (not in its subcategories); null when unknown. */\n// productCount: number | null;\n// /** Ancestors, top-level first — only filled by fetchCategoryBySlug; [] elsewhere. */\n// breadcrumbs: Breadcrumb[];\n// }\n// export interface FacetChoice {\n// /** The choice id — what toggleChoice / searchCatalog filter on. */\n// id: string;\n// name: string;\n// /** Hex color for a swatch facet; null for a text facet. */\n// colorCode: string | null;\n// /** Products in the scope carrying this choice (linked choices counted into their primary). */\n// count: number;\n// /** Ids of linked choices shown under this one (\"Light red\" under \"Red\") — the filter sends them along; [] when none. */\n// childIds: string[];\n// }\n// /** One filterable customization across the catalog (or a category): \"Color\" with its choices. */\n// export interface Facet {\n// /** The customization id (an option's or a modifier's). */\n// id: string;\n// name: string;\n// /** Options and modifiers filter on different fields — the store keeps them apart. */\n// kind: \"option\" | \"modifier\";\n// isColor: boolean;\n// choices: FacetChoice[];\n// }\n// /** The catalog's (or category's) lowest and highest product price, as numbers in site currency — the slider's bounds. */\n// export interface PriceRange {\n// min: number;\n// max: number;\n// }\n// /** What the filter panel needs beyond the product page: the facets and the price bounds of the scope. */\n// export interface FacetData {\n// facets: Facet[];\n// priceRange: PriceRange | null;\n// }\n// export interface CartLine {\n// /** The cart line id — what update/remove take (NOT the product id). */\n// lineItemId: string;\n// productName: string;\n// quantity: number;\n// /** Per-unit price, formatted. */\n// unitPrice: string;\n// /** Line total, formatted. */\n// linePrice: string;\n// /** Struck line total (the catalog price × quantity) when the line is discounted; null otherwise. */\n// compareAtLinePrice: string | null;\n// /** Units the store can still sell of this line; null when uncounted. The stepper's ceiling. */\n// availableQuantity: number | null;\n// /** Resolved https URL (\"\" when none). */\n// imageUrl: string;\n// /** The item's page URL when Wix returned one; \"\" otherwise (a headless site links by slug). */\n// productUrl: string;\n// /** Human-readable option/modifier labels, e.g. [\"Color: Ink\", \"Size: M\"]. */\n// descriptionLines: string[];\n// /** IN_STOCK | PARTIALLY_IN_STOCK | OUT_OF_STOCK | REMOVED_FROM_CATALOG — not IN_STOCK → the line can't be checked out as-is. */\n// status: string;\n// /**\n// * The recurring plan's terms for a subscription line — \"Monthly plan · every month · 12 payments\";\n// * \"\" for a one-time purchase. A subscription line must read as one in the cart.\n// */\n// subscription: string;\n// }\n// /** A named amount from the cart estimate (a discount, a fee, a tax), formatted. */\n// export interface CartAmount {\n// name: string;\n// amount: string;\n// }\n// export interface Cart {\n// lines: CartLine[];\n// /** Sum of line quantities. */\n// itemCount: number;\n// /** Formatted subtotal (after item discounts) — from the cart estimate, not hand-summed. */\n// subtotal: string;\n// /** Formatted CART-level discount total (a coupon or cart rule) — \"\" when none; item discounts are already in subtotal. */\n// discount: string;\n// /** Every applied discount by name (coupon, automatic rule) with its amount; [] when none. */\n// discounts: CartAmount[];\n// /** Additional fees (handling, platform) with their amounts; [] when none. */\n// fees: CartAmount[];\n// /** Taxes by name; [] when none or not yet calculable (no address). */\n// taxes: CartAmount[];\n// /** Line prices already include tax — don't render a tax row then. */\n// pricesIncludeTax: boolean;\n// /** Formatted total to pay before delivery (shipping resolves at checkout); \"\" when unknown. */\n// total: string;\n// /** The applied coupon; null when none. */\n// coupon: {\n// id: string;\n// code: string;\n// } | null;\n// /** The buyer's note to the merchant; \"\" when none. */\n// note: string;\n// currency: string;\n// }\n// Storefront DTOs — the serializable shapes every hook, component, and page consumes.\n// These are plain JSON: safe to pass as Astro island props or across a server/client\n// boundary. Image values are already-resolved https URLs (never wix:image://), and every\n// displayable price is a ready formatted string.\nexport {};\n"