import { UIDLEcommerceSettings, UIDLInvoiceSettings } from '@teleporthq/teleport-types' import { CartCurrency, CartLinePricing, ProductDiscounts, ProductOptions, StorefrontTax, } from '@teleporthq/teleport-shared' import { buildWorkflowEcommerceSettingsPayload } from './ecommerce-api-routes-generator' import { buildCartPricingSettings } from './cart-pricing-settings' import { PAYMENT_PROVIDER_KIND_FILTER_CODE, toClientPaymentProvider, } from './payment-provider-kinds' import { generateRegionalPricingModuleCode, generateRegionalPricingProviderCode, resolveRegionalPricing, } from './ecommerce-regional-pricing-code' import { generateDiscountEngineModuleCode, generateDiscountChoiceProviderCode, generateDiscountEngineProviderCode, generateDiscountMetaCode, generateDiscountProjectionCode, resolveDiscountEngine, } from './ecommerce-discount-engine-code' import { generateGiftCardMetaCode, generateGiftCardModuleCode, generateGiftCardProviderCode, resolveGiftCards, } from './ecommerce-gift-card-code' /** * Records where this VISIT came from, so the checkout can stamp it onto the * order and a merchant can report revenue per campaign. * * ALWAYS emitted, unlike the database-cart helpers: attribution has nothing to * do with where the cart is stored, and the provider's mount effect calls this * unconditionally — gating it would be a ReferenceError on every * localStorage-only store. * * The reader half is baked into the place-order workflow by the editor * (features/workflows/templates/builders/ecommerce/order-attribution-script.ts). * ⚠️ ATTRIBUTION_KEY and the key that module reads MUST match. * * SESSION storage, not local: attribution belongs to a visit. A shopper who * arrives from a campaign today and returns directly next week is a direct * visit the second time, and localStorage would credit the campaign forever. * * Writes only when nothing is stored yet — that is what makes it FIRST touch. * A shopper who lands on a campaign URL, browses, and checks out three pages * later has a clean address bar by then, so reading the URL at checkout would * attribute every one of those orders to nothing. */ const ORDER_ATTRIBUTION_WRITER = ` const ATTRIBUTION_KEY = 'tq_attribution' function captureOrderAttribution() { if (typeof window === 'undefined') return try { if (window.sessionStorage.getItem(ATTRIBUTION_KEY)) return const params = new URLSearchParams(window.location.search || '') // Capped to the column width: a 4KB UTM value is a broken link or an // attack, and a rejected INSERT would fail the whole checkout. const read = function (key) { const value = params.get(key) return value ? String(value).slice(0, 255) : null } let referrerHost = null try { if (document.referrer) { const parsed = new URL(document.referrer) // Same-origin referrers are internal navigation, not a traffic source. if (parsed.host && parsed.host !== window.location.host) { referrerHost = parsed.host.slice(0, 255) } } } catch (e) { referrerHost = null } window.sessionStorage.setItem( ATTRIBUTION_KEY, JSON.stringify({ utm_source: read('utm_source'), utm_medium: read('utm_medium'), utm_campaign: read('utm_campaign'), utm_term: read('utm_term'), utm_content: read('utm_content'), // The path only: a query string can carry an email or a token, and this // is written into the merchant's database. landing_path: String(window.location.pathname || '/').slice(0, 512), referrer_host: referrerHost, }) ) } catch (e) { /* private mode, disabled storage: the order is simply unattributed */ } } ` /** * A subscription line and a digital-only cart, as the provider reads them off * the lines the add-to-cart node and hydration stamp. * * ⚠️ PAIRED with the editor: `formatBillingPeriod` mirrors * `constants/subscriptions.ts` and `cartRecurringSummary` mirrors * `utils/subscriptions/recurring-cart.ts` (teleport-gui), so the canvas * simulator and the published checkout print the same words. The shared * parity table in `ecommerce-subscriptions.test.ts` pins them together. */ const SUBSCRIPTION_HELPERS = ` const TQ_BILLING_INTERVALS = ['day', 'week', 'month', 'year'] function tqIsRecurringProduct(product) { return String((product && product.payment_type) || '').trim().toLowerCase() === 'recurring' } // Whatever the driver spelled; anything else is a month. function tqNormalizeBillingInterval(value) { const normalized = String(value == null ? '' : value).trim().toLowerCase() return TQ_BILLING_INTERVALS.indexOf(normalized) !== -1 ? normalized : 'month' } // A positive whole number of intervals; anything else is 1. function tqNormalizeBillingIntervalCount(value) { const parsed = Math.floor(Number(value)) return isFinite(parsed) && parsed >= 1 ? parsed : 1 } // A positive whole number read off an INTEGER column; null for NULL, 0 or // anything unreadable — "no trial". function tqPositiveCount(value) { if (value === null || value === undefined || value === '') return null const parsed = Math.floor(Number(value)) return isFinite(parsed) && parsed > 0 ? parsed : null } // "month", "3 months" — the per-cycle period a recurring price is followed by. function formatBillingPeriod(interval, intervalCount) { const unit = tqNormalizeBillingInterval(interval) const count = tqNormalizeBillingIntervalCount(intervalCount) return count === 1 ? unit : count + ' ' + unit + 's' } // The subscription in the basket. At most one: the add-to-cart node refuses a // second, and refuses to mix it with one-time lines. function cartRecurringLine(items) { for (let i = 0; i < items.length; i++) { if (items[i] && __deBool(items[i].isRecurring, false)) return items[i] } return null } // Whether the cart buys a gift card — the flag is stamped onto every line by // hydration and by the add-to-cart node. Such a cart is paid online and // issued by email, and can neither be paid with a card nor discounted. function cartHasGiftCardLine(items) { for (let i = 0; i < items.length; i++) { if (items[i] && __deBool(items[i].isGiftCard, false)) return true } return false } // "Billed every month · first charge today", or with a free trial ahead of // the first charge: "Billed every month · free for 14 days, then every month". function cartRecurringSummary(line) { const period = formatBillingPeriod(line.recurringInterval, line.recurringIntervalCount) const trialDays = tqPositiveCount(line.trialDays) const start = trialDays === null ? 'first charge today' : 'free for ' + trialDays + (trialDays === 1 ? ' day' : ' days') + ', then every ' + period return 'Billed every ' + period + ' \\u00b7 ' + start } // Every line is delivered without a parcel — a download, or a gift card sent // by email — and there is at least one: nothing to ship, so no delivery fee, // no method to pick and no parcel address. function isDigitalOnlyCart(items) { if (!items || items.length === 0) return false for (let i = 0; i < items.length; i++) { if (!items[i]) return false if (!__deBool(items[i].isDigital, false) && !__deBool(items[i].isGiftCard, false)) return false } return true } // A line's kind: a subscription checks out alone at quantity one, a gift card // is issued by email once paid, a digital line is delivered from the order // page, anything else ships. One order holds ONE kind, so the cart does too — // and it is charged in ONE currency, so the cart holds one currency as well. function tqCartLineKind(line) { if (__deBool(line && line.isRecurring, false)) return 'subscription' if (__deBool(line && line.isGiftCard, false)) return 'gift-card' if (__deBool(line && line.isDigital, false)) return 'digital' return 'physical' } const TQ_CART_KIND_LABELS = { subscription: 'Subscriptions', 'gift-card': 'Gift cards', digital: 'Digital products', physical: 'Physical products', } // Why \`item\` cannot join \`items\`, or null when it can — the same verdict and // wording the add-to-cart workflow node gives, so a custom caller of // \`addToCart\` is told the same thing the storefront button says. function tqCartKindRefusal(items, item) { const others = (items || []).filter((line) => !tqSameCartLine(line, item)) const existing = (items || []).length !== others.length const kind = tqCartLineKind(item) const oneSubscription = { added: false, reason: 'one-subscription', message: 'Subscriptions are checked out on their own. Finish or empty your current cart first.', } if (existing && kind === 'subscription') return oneSubscription if (others.length === 0) return null if (kind === 'subscription' || tqCartLineKind(others[0]) === 'subscription') return oneSubscription if (tqCartLineKind(others[0]) !== kind) { return { added: false, reason: 'mixed-cart', message: TQ_CART_KIND_LABELS[kind] + ' need a separate order. Complete your current order or remove the other items from your cart first.', } } return __ccCurrencyRefusal({ lines: others, currency: item.currency }) } // Same shape \`computeShippingMeta\` returns, for a cart that ships nothing. function digitalOnlyShippingMeta(goodsTotal) { return { shippingIsFree: true, shippingPrice: 0, totalWithShipping: roundMoney(goodsTotal), freeDeliveryProgress: '100%', freeDeliveryRemaining: 0, } } ` /** * Cart lines bought with product options, read through the shared option * helpers spliced above them (`ProductOptions.generateProductOptionsHelperCode`). * A line is identified by product, variant AND its chosen options — the same * identity the add-to-cart node, the add-to-cart limit check and the database * cart route use. A line without options has neither field and matches exactly * as before. */ const CART_LINE_OPTIONS_HELPERS = ` // One cart line: the same product, variant and chosen options. The options' // key is a short hash, so the answers themselves must be identical too. function tqSameCartLine(line, item) { return ( line.productId === item.productId && (line.variantId || '') === (item.variantId || '') && (line.configurationKey || '') === (item.configurationKey || '') && (!item.configurationKey || line.configuration === item.configuration) ) } // A caller's line with its answers as the canonical string and their key, // computed when the caller sent none. A line without answers carries neither. function tqWithLineConfiguration(item) { const configuration = __poCanonical(item && item.configuration) if (!configuration) { return Object.assign({}, item, { configuration: '', configurationKey: '' }) } const key = item.configurationKey ? String(item.configurationKey).slice(0, 64) : __poKey(configuration) return Object.assign({}, item, { configuration: configuration, configurationKey: key }) } // The files a line's upload options carry, as the cart's file links render // them: https only — a crafted javascript: or data: link never reaches an href // — and at most 10. \`isImage\` is a 'true'/'false' string for the page's // rendering conditions. function tqConfigurationFiles(raw) { const files = [] const entries = __poParseEntries(raw) for (let i = 0; i < entries.length && files.length < 10; i++) { const value = entries[i].value if (!Array.isArray(value)) continue for (let j = 0; j < value.length && files.length < 10; j++) { if (__poIsFileRef(value[j])) { files.push({ name: value[j].name || value[j].id, url: value[j].url, isImage: __poIsImage(value[j]) ? 'true' : 'false', }) } } } return files } ` export const generateEcommerceContextFileContent = ( ecommerceSettings: UIDLEcommerceSettings, invoiceSettings?: UIDLInvoiceSettings, dataSourceId?: string | null, // When true, the provider layers a best-effort database cart on top of // localStorage (sync on change, hydrate from DB when local is empty) via // the generated /api/cart/[op] route. Only enabled for Postgres datasources // — see NextEcommerceProjectPlugin.generateApiRoutes. When false the output // is byte-identical to the pure-localStorage cart. cartDbEnabled?: boolean, // When true, the module publishes the workflow-facing settings payload on // `window.__teleportEcommerceSettings` at module-eval time (this module is // imported by _app, so the global exists before any workflow can run). The // client-side `ecommerce-get-settings` workflow node reads it instead of // fetching /api/ecommerce/settings — the route bakes the SAME payload as a // literal, so the global can never disagree with it. Only the real-settings // branch of the project plugin turns this on; the settings-less fallback // context (emitted just so cart-hook imports resolve) must NOT publish a // defaults-shaped payload that the workflow node would mistake for real // merchant settings. emitWorkflowSettingsGlobal?: boolean, // When true, `utils/ecommerce/asset-urls` and the `/api/ecommerce/assets` // route it calls are part of this project, so cart hydration can turn a // product image stored as a PROJECT-ASSET ID into a URL the browser can // load. Off for a datasource that has no `teleport_assets` mirror to read // (and for the settings-less fallback context, which has no enrichment at // all): the stored value is then used exactly as the row holds it, which is // what those stores always did — their media is direct URLs. assetLookupEnabled?: boolean, // The datasource's type, which decides whether gift cards can be taken at // all: their balance ledger is read and debited through SQL only a Postgres // datasource runs (see `resolveGiftCards`). Absent for the settings-less // fallback context, which then takes none. dataSourceType?: string | null ): string => { const settingsJson = JSON.stringify(buildSettingsObject(ecommerceSettings, invoiceSettings)) const maxQtyLiteral = ecommerceSettings.stockManagementConfig?.maxQuantityPerProduct ?? null // Baked into the browser bundle without the names of any secret. Each cart // is offered only the providers whose terms cover what it holds (see // `tqProvidersForCart`). const clientPaymentProviders = (ecommerceSettings.paymentProviders || []).map( toClientPaymentProvider ) const paymentProvidersJson = JSON.stringify(clientPaymentProviders) const hasPaymentProviders = clientPaymentProviders.length > 0 // The cart-pricing snapshot this storefront shares with the checkout's server // step (see cart-pricing-settings.ts): the mirrors below are its fields. const pricingSettings = buildCartPricingSettings( ecommerceSettings, invoiceSettings, dataSourceId || null, dataSourceType || null ) // Mirrored for the place-order script, which refuses on the server a // provider that may not take the cart. const paymentKindsMirrorJson = JSON.stringify(pricingSettings.paymentKinds) // Mirrored into `workflow_cart_settings` so the checkout's page-load workflow // can pre-select a capable provider for a recurring cart without reaching // into React — the same channel `deliveryConfig` travels. const subscriptionsMirrorJson = JSON.stringify(pricingSettings.subscriptions) // Static nested category tree for the storefront category filter, exposed as // `useEcommerce().ecommerceCategories` (the `E-Commerce Categories` global). // Each node's `name`/`description` are the main-language values; per-language // overrides live in `node.translations[locale]` (see // `resolveCategoryTranslations` below) and are resolved client-side by // `router.locale` — no per-locale re-fetch needed, the whole tree (every // language) is baked into this one JSON blob at export time. const ecommerceCategoriesJson = JSON.stringify(ecommerceSettings.categories || []) const fetchStoreLocations = ecommerceSettings.storePickupEnabled === true // Snapshot the delivery config at build time so the persist-cart-settings // useEffect can write it to localStorage without depending on the in-render // `settings` memo (which is declared further down — referencing it from an // earlier hook would hit the temporal dead zone). cart-get-total reads // these out of localStorage so the place-order workflow stays in sync with // whatever Settings → Delivery values were live at the last build. // // `deliveryEnabled` / `storePickupEnabled` ride along because a delivery fee // may only be charged for an order the store actually DELIVERS: the // place-order assemble script needs both to decide whether the buyer's // fulfilment choice can attract a fee at all (a pickup-only store has no // delivery tab to click, so its `deliveryOption` state never leaves its // `'delivery'` default and would otherwise be charged the stale rate). const deliveryConfigJson = JSON.stringify(pricingSettings.deliveryConfig) // Percentage the storefront must ADD to every stored product price, or 0. // // `teleport_products.price` is NET when the merchant picked "Added on top" // and already GROSS when they picked "Included in price", so exactly one of // the two modes changes what the shopper is quoted. Mirrors // `resolveStorefrontTaxRate` in the editor (teleport-gui // `features/e-commerce/utils/storefront-tax.ts`) — including the deliberate // choice NOT to gate on `invoiceSettings.enabled` (which is auto-cleared when // the last payment provider is removed) and the treatment of a missing // `taxIncludedInPrice` as "added on top", matching every other legacy-document // coercion of that field. const storefrontTaxRate = resolveStorefrontTaxRate(invoiceSettings) // Shipping zones + tax jurisdictions. Null for a store without the feature, // which then gets none of the fragments below and prices exactly as before. const regional = resolveRegionalPricing(ecommerceSettings, dataSourceId) const regionalModuleCode = regional && pricingSettings.regional ? generateRegionalPricingModuleCode(regional, pricingSettings.regional.store) : '' // The discount engine's rule feed and the gift-card tender. Each is null for // a store without the feature, which then gets none of that feature's // loaders or effects — the engine itself is always emitted (see // `generateDiscountProjectionCode`), pricing vouchers in legacy mode. const discountEngine = resolveDiscountEngine(ecommerceSettings, dataSourceId) const discountEngineModuleCode = discountEngine ? generateDiscountEngineModuleCode(discountEngine) : '' const discountEngineProviderCode = discountEngine ? generateDiscountEngineProviderCode() : '' const giftCards = resolveGiftCards(ecommerceSettings, dataSourceId, dataSourceType) const giftCardModuleCode = giftCards ? generateGiftCardModuleCode(giftCards) : '' const giftCardProviderCode = giftCards ? generateGiftCardProviderCode() : '' // Cart hydration re-reads every line off `teleport_products`, so the media it // stamps back onto the line is whatever that column holds — a URL for a stock // photo, a bare PROJECT-ASSET ID when the merchant picked an image that // already lived in the project. The id has to become a URL here or the cart // and checkout thumbnails render ``, which the browser // resolves against the site origin and 404s. // // One batched lookup per hydration pass, and none at all for a store whose // media is direct URLs (`loadAssetUrlMap` finds nothing to resolve and never // touches the network). See `utils/ecommerce/asset-urls`. const assetMapLines = assetLookupEnabled ? [ ' var assetUrlMap = await loadAssetUrlMap(prepared.map(function(entry) { return entry.rawImage }))', ] : [] // `resolveMediaUrl` reports anything that did not become a loadable URL as // ABSENT, falling back to the URL the line already carried. That is what // stops a lookup outage from blanking a thumbnail an earlier pass resolved, // and what stops a bare id from ever reaching the ``. const resolveImageLines = assetLookupEnabled ? [' var image = resolveMediaUrl(entry.rawImage, assetUrlMap, item.image)'] : [' var image = entry.rawImage'] // What the discount engine and the tax rows price a line by, re-read with // everything else: the product's category ids INCLUDING ancestors (a rule // for "Food" covers "Food › Bread"), whether it IS a gift card (never // discounted, never payable with one), whether it is billed on a schedule // (the plan the provider bills, never discounted) or delivered as a download // (no shipping), and — for a store with regional pricing, whose // weight-tiered rates are the only reader — its weight. A line of a product // with options also re-prices its options (see `__clPriceLineOptions` in CartLinePricing). const enrichedFields = [ 'name', 'price', 'image', 'variant', 'currency', 'currencySymbol', 'slug', 'originalPrice', 'discountType', 'discountValue', 'discountAmount', 'isGiftCard', 'isRecurring', 'recurringInterval', 'recurringIntervalCount', 'trialDays', 'isDigital', 'basePrice', 'configurationLabel', 'configurationPriceDelta', 'configurationStale', 'quantity', ...(regional ? ['weight', 'weightUnit'] : []), ] const enrichFnCode = dataSourceId ? [ // Reports whether the freshly-built line differs from the stored one on // any field enrichment owns, so an unchanged cart can be returned by // reference. Compared field-by-field rather than by JSON.stringify: // key order is not guaranteed across engines and a false "changed" // would reintroduce the redundant write this exists to avoid. `var TQ_ENRICHED_FIELDS = ${JSON.stringify(enrichedFields)}`, 'function tqLineChanged(before, after) {', ' for (var f = 0; f < TQ_ENRICHED_FIELDS.length; f++) {', ' var key = TQ_ENRICHED_FIELDS[f]', ' var a = before ? before[key] : undefined', ' var b = after[key]', ' if ((a == null) !== (b == null)) return true', ' if (a != null && String(a) !== String(b)) return true', ' }', " if (String((before && before.categoryIds) || '') !== String(after.categoryIds || '')) return true", ' var beforeSwatches = (before && before.variantSwatches) || []', ' var afterSwatches = after.variantSwatches || []', ' if (beforeSwatches.length !== afterSwatches.length) return true', ' for (var sw = 0; sw < afterSwatches.length; sw++) {', ' if ((beforeSwatches[sw] || {}).color !== (afterSwatches[sw] || {}).color) return true', ' }', ' return false', '}', 'async function enrichCartItems(items) {', ' if (!items || items.length === 0) return items', // EVERY line with a product id is re-read, not just the bare ones. // // It used to be "no name, or a variant" — enough when a line's price // could only go stale if the merchant edited it. A per-product discount // starts and expires on a schedule, so a line added at full price // yesterday must be re-priced today (and vice versa). Hydration is the // one place regenerated code can heal that, and it is a single batched // /api/data round trip for the whole cart either way. ' var needsEnrichment = items.filter(function(i) { return i.productId })', ' if (needsEnrichment.length === 0) return items', ' var productIds = [], seen = {}', ' for (var k = 0; k < needsEnrichment.length; k++) {', ' if (!seen[needsEnrichment[k].productId]) {', ' productIds.push(needsEnrichment[k].productId)', ' seen[needsEnrichment[k].productId] = true', ' }', ' }', ' try {', " var res = await fetch('/api/data/' + PRODUCTS_DATA_SOURCE_ID + '/select', {", " method: 'POST',", " headers: { 'Content-Type': 'application/json' },", ' body: JSON.stringify({', " tableName: 'teleport_products',", " filters: [{ field: 'id', value: productIds, operator: '=' }],", ' limit: productIds.length', ' })', ' })', ' if (!res.ok) return items', ' var data = await res.json()', ' var rows = data.rows || []', ' var productMap = {}', ' for (var r = 0; r < rows.length; r++) { productMap[rows[r].id] = rows[r] }', // Fetch the purchasable variant rows for these products so variant lines // can show the correct price/image/label. Best-effort — a failure just // falls back to product-level values. ' var variantMap = {}', ' var hasVariantLines = items.some(function(i) { return i.variantId })', ' if (hasVariantLines) {', ' try {', " var vres = await fetch('/api/ecommerce/variants?productIds=' + productIds.map(encodeURIComponent).join(','))", ' if (vres.ok) {', ' var vbody = await vres.json()', ' var vrows = (vbody && vbody.variants) || []', ' for (var vr = 0; vr < vrows.length; vr++) { variantMap[vrows[vr].id] = vrows[vr] }', ' }', ' } catch (ve) {}', ' }', // Whether anything actually moved. The caller persists + re-renders only // when the array is a NEW one, and now that EVERY line is re-read (not // just the bare ones) a fresh `map` would report a change on every sync // of an unchanged cart — one redundant localStorage write and one // redundant render each time. Returning the original array when nothing // differs keeps the old no-op contract exactly. ' var didChange = false', ' function markIfChanged(before, after) {', ' if (tqLineChanged(before, after)) { didChange = true }', ' return after', ' }', // Each line paired with the row it was re-read from, plus the media // value EXACTLY as the row holds it — a URL for a stock photo, a bare // project-asset id when the merchant picked an image that already lived // in the project. Collected in one pass so the whole cart's asset ids // resolve in ONE lookup instead of one per line. ' var prepared = items.map(function(item) {', ' var product = productMap[item.productId]', ' if (!product) return { item: item, product: null, variant: null, rawImage: null }', ' var variant = item.variantId ? variantMap[item.variantId] : null', ' var rawImage = product.image_url || product.imageUrl || null', ' if (variant && variant.image_url) rawImage = variant.image_url', ' return { item: item, product: product, variant: variant, rawImage: rawImage }', ' })', ...assetMapLines, ' var enriched = prepared.map(function(entry) {', ' var item = entry.item', ' var product = entry.product', ' if (!product) return item', ' var variant = entry.variant', ...resolveImageLines, ' var variantLabel = item.variant || ""', ' var variantSwatches = item.variantSwatches || []', ' if (variant) {', ' variantLabel = __clVariantLabel(product.variant_options, variant.options)', ' variantSwatches = __clVariantSwatches(product.variant_options, variant.options)', ' }', // What the line costs and what the totals read off its product — the // same rule the checkout's server step prices the order with (see // CartLinePricing in teleport-shared). The markdown live RIGHT NOW is // re-stamped on every hydration, back to null/0 once it has expired, so // the placed order records what was actually given away. ` var priced = __clPriceLine(item, product, variant, Date.now(), ${ regional ? 'true' : 'false' })`, ' return markIfChanged(item, Object.assign({}, item, {', " name: product.name || item.name || '',", ' image: image,', ' variant: variantLabel,', ' variantSwatches: variantSwatches,', ' currency: product.currency || item.currency || null,', ' currencySymbol: product.currency_symbol || product.currencySymbol || item.currencySymbol || null,', ' slug: product.slug || item.slug || null,', ' }, priced))', ' })', ' return didChange ? enriched : items', ' } catch(e) { return items }', '}', ].join('\n') : 'function enrichCartItems(items) { return Promise.resolve(items) }' const dsConstCode = dataSourceId ? "const PRODUCTS_DATA_SOURCE_ID = '" + dataSourceId + "'" : '' // ── Database-backed cart (best-effort, optional) ────────────────────────── // Top-level helpers (session id + DB sync). Emitted only when cartDbEnabled. const cartDbHelpers = cartDbEnabled ? `const CART_SESSION_KEY = 'workflow_cart_session_id' // Timestamp written by the workflow cart-clear handler when the cart is // emptied on purpose (order placed, or the shopper hit "Clear cart"). Read by // the DB reconcile effect below so a deliberate clear is never mistaken for a // first-visit empty cart worth restoring. Short-lived: it only has to outlive // the in-flight cart-sync round trip, and it must NOT suppress a legitimate // cross-device restore on the shopper's next visit. const CART_CLEARED_AT_KEY = 'workflow_cart_cleared_at' const CART_CLEARED_GRACE_MS = 60000 function wasCartJustCleared() { if (typeof window === 'undefined') return false try { const raw = localStorage.getItem(CART_CLEARED_AT_KEY) if (!raw) return false const age = Date.now() - Number(raw) // A clock change (or a corrupted value) must never wedge the cart into a // permanently un-restorable state, so anything outside the grace window — // in either direction — is treated as stale and dropped. const fresh = isFinite(age) && age >= 0 && age < CART_CLEARED_GRACE_MS if (!fresh) { localStorage.removeItem(CART_CLEARED_AT_KEY) } return fresh } catch { return false } } // Stable per-browser guest id, used to scope a cart for users who aren't // logged in. Logged-in carts are keyed server-side by the auth token instead. function getOrCreateSessionId() { if (typeof window === 'undefined') return null try { const existing = localStorage.getItem(CART_SESSION_KEY) if (existing) return existing const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0 const v = c === 'x' ? r : (r & 0x3) | 0x8 return v.toString(16) }) localStorage.setItem(CART_SESSION_KEY, id) return id } catch (e) { return null } } // Local state is the source of truth; the DB is a cross-session backup. This // pushes the current cart to the server and never throws or blocks the UI. function persistCartToDb(items) { if (typeof window === 'undefined') return Promise.resolve(null) try { const payload = (items || []).map(function (i) { const line = { productId: i.productId, variantId: i.variantId || null, quantity: i.quantity } // A line bought with product options carries them: the route stores them // where its table has the columns, and keeps the line out otherwise. if (i.configurationKey) { line.configuration = i.configuration line.configurationKey = i.configurationKey } return line }) // Resolves with the server's answer so the caller can react to a MERGE: on // the first sync after sign-in the server claims the guest cart, refuses to // overwrite the union with this (pre-login) snapshot, and hands back the // merged lines instead. Ignoring that answer would leave the tab showing // half the cart the database now holds. // The language of the page the cart was synced from: the abandoned-cart // reminder reads it back so the shopper is emailed in the language they // browsed in. Read live off the router (the page data goes stale after a // client-side language switch) by the shared locale module. var pageLocale = emailLocale.getClientLocale() return fetch('/api/cart/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: payload, sessionId: getOrCreateSessionId(), locale: pageLocale }), }) .then(function (res) { return res.ok ? res.json() : null }) .catch(function () { return null }) } catch (e) { return Promise.resolve(null) } } ` : '' // In-provider reconcile effect: on mount, local cart wins (and is backed up // to the DB); if local is empty, hydrate from the DB — unless the cart was // just deliberately emptied (see CART_CLEARED_AT_KEY below). const cartDbMountEffect = cartDbEnabled ? ` // Applies a cart the SERVER holds to this tab: state, meta and localStorage, // then the async enrichment pass. One implementation, because the two callers // (first-visit hydrate, post-sign-in merge) must produce identical results — // a shopper should not be able to tell which path filled their cart. // // \`keepLocal\` is this tab's cart when the server could not store lines bought // with product options (its table predates the columns): those lines are // kept here rather than dropped with the server's answer. const applyServerCart = useCallback((dbItems, keepLocal) => { const rows = dbItems || [] if (!rows.length) return const serverLines = rows.map(function (d) { const vid = d.variantId || null const line = { id: d.productId + (vid ? '__' + vid : '') + (d.configurationKey ? '__' + d.configurationKey : ''), productId: d.productId, variantId: vid, quantity: d.quantity, } if (d.configurationKey) { line.configuration = d.configuration line.configurationKey = d.configurationKey } return line }) const localOnly = (keepLocal || []).filter(function (local) { return local.configurationKey && !serverLines.some(function (line) { return tqSameCartLine(line, local) }) }) const mapped = localOnly.length ? serverLines.concat(localOnly) : serverLines setCartItems(mapped) setCartMeta(computeCartMeta(mapped)) saveCartToStorage(mapped) enrichCartItems(mapped).then(function (enriched) { if (enriched !== mapped) { setCartItems(enriched) setCartMeta(computeCartMeta(enriched)) saveCartToStorage(enriched) } }) }, []) // The sync endpoint answers with the merged cart on the FIRST sync after a // guest signs in — it has just claimed the guest cart and refuses to let this // tab's pre-login snapshot overwrite the union. Anything else is a normal // sync with nothing to adopt. const adoptMergedCart = useCallback((response) => { if (response && response.merged) { applyServerCart( response.items, response.configurationsPersisted === false ? cartItemsRef.current : null ) } }, [applyServerCart]) const cartDbInitRef = useRef(false) useEffect(() => { if (cartDbInitRef.current) return cartDbInitRef.current = true if (typeof window === 'undefined') return const local = loadCartFromStorage() if (local && local.length > 0) { // The push doubles as the guest-cart claim: on the first sync after // sign-in the server merges the guest cart into the account's and answers // with the union instead of accepting this snapshot. persistCartToDb(local).then(adoptMergedCart) return } // "Empty local cart" is ambiguous: it means either "first visit on this // device, restore my cart" or "I just checked out / cleared the cart". // The workflow \`cart-clear\` handler stamps the second case and also pushes // the empty cart to /api/cart/sync — but that push is fire-and-forget, so a // post-order redirect can mount this provider before it lands. Without the // stamp we would then re-hydrate the cart the shopper just ordered, write // it back to localStorage, and the buyer lands on the confirmation page // with a full cart. The window only has to outlive the in-flight sync. if (wasCartJustCleared()) return try { fetch('/api/cart/load', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: getOrCreateSessionId() }), }) .then(function (res) { return res.ok ? res.json() : { items: [] } }) .then(function (data) { applyServerCart(data && data.items) }) .catch(function () {}) } catch (e) {} }, []) ` : '' // In-provider debounced persist effect: mirror every cart change to the DB. const cartDbPersistEffect = cartDbEnabled ? ` const cartPersistTimerRef = useRef(null) const cartPersistPrimedRef = useRef(false) useEffect(() => { if (!isHydrated) return // Skip the first post-hydration run: that's the initial load, already // reconciled above — persisting it would just echo it straight back. if (!cartPersistPrimedRef.current) { cartPersistPrimedRef.current = true return } // Persist every change, INCLUDING an emptied cart: after an order the // cart is cleared, and pushing the empty state clears the DB cart too so // a later visit (empty localStorage) doesn't re-hydrate a stale cart. if (cartPersistTimerRef.current) clearTimeout(cartPersistTimerRef.current) const snapshot = cartItems || [] cartPersistTimerRef.current = setTimeout(function () { persistCartToDb(snapshot).then(adoptMergedCart) }, 300) return function () { if (cartPersistTimerRef.current) clearTimeout(cartPersistTimerRef.current) } }, [cartItems, isHydrated]) ` : '' // Workflow-facing settings global — same literal the /api/ecommerce/settings // route responds with. Published at module-eval time (before any render or // click) so the client-side `ecommerce-get-settings` workflow node resolves // synchronously instead of paying a server round trip per click. const workflowSettingsGlobalCode = emitWorkflowSettingsGlobal ? ` const WORKFLOW_ECOMMERCE_SETTINGS = ${JSON.stringify( buildWorkflowEcommerceSettingsPayload(ecommerceSettings, invoiceSettings) )} if (typeof window !== 'undefined') { window.__teleportEcommerceSettings = WORKFLOW_ECOMMERCE_SETTINGS } ` : '' // The last gate before a stored media value becomes an ``. // // Hydration resolves every line it can re-read, but a line whose product row // has since been DELETED is returned untouched — there is nothing left to // resolve it from — and a cart written before this resolution existed still // holds the bare id. Rendering that id puts `` on the page // and the browser asks the site for `/`. Being a display projection, // this drops it exactly like the other fields it shadows, and nothing that is // stored or charged changes. const displayImageLine = assetLookupEnabled ? ` image: isDirectAssetUrl(item.image) ? item.image : null,` : '' // Emitted alongside `assetMapLines` / `resolveImageLines`, never on its own: // an import of a module the project plugin did not write would fail the build // ("Module not found: Can't resolve './utils/ecommerce/asset-urls'"). const assetUrlsImport = assetLookupEnabled ? "import { isDirectAssetUrl, loadAssetUrlMap, resolveMediaUrl } from './utils/ecommerce/asset-urls'\n" : '' // The cart sync sends the page language; the module is emitted alongside // the cart route (see project-plugin.ts), so it exists exactly when this // import does. A default import of the CommonJS module, like the workflow // hook's import of the runtime — a `require` in this ES module would mix // module systems. const cartLocaleImport = cartDbEnabled ? "import emailLocale from './utils/email/email-locale'\n" : '' // The render-time pricing of the provider, in its two shapes. Regional stores // price every figure from one quote for the checkout's destination; every // other store keeps the single-rate arithmetic it has always used. // A cart that ships nothing is never charged for shipping, whichever way the // store prices it: the single-rate arithmetic is handed no delivery config // (which is how it already prices a store without one), the regional quote // is set aside for a zero fee. const pricingCode = regional ? `${generateRegionalPricingProviderCode()} const shippingMeta = useMemo( () => digitalOnly ? digitalOnlyShippingMeta(regionalQuote.goodsGross) : regionalShippingMeta(regionalQuote), [regionalQuote, digitalOnly] ) const cartGoodsTotal = regionalQuote.goodsGross ` : ` const shippingMeta = useMemo( () => computeShippingMeta( cartMeta.total, digitalOnly ? null : settings.Delivery, settings.deliveryEnabled === true ), [cartMeta.total, settings.Delivery, settings.deliveryEnabled, digitalOnly] ) const cartGoodsTotal = cartMeta.total ` // The lines the discount engine prices: on a regional store already GROSS in // the destination's tax (so the engine is handed a rate of 0, never asked to // tax them again), otherwise the stored NET lines with the baked rate. const discountMetaCode = generateDiscountMetaCode({ itemsExpression: regional ? 'regionalVoucherItems(regionalQuote, cartItems)' : 'cartItems', taxRateExpression: regional ? '0' : 'STOREFRONT_TAX_RATE', itemsDeps: regional ? 'cartItems, regionalQuote, ' : 'cartItems, ', engineEnabled: discountEngine !== null, }) const giftCardMetaCode = generateGiftCardMetaCode(giftCards !== null) // The product options a line was bought with, as the cart's line surfaces // bind them: the short label, the uploaded files (https links only), whether // the line carries options, and whether they changed since it was added. // Flags are 'true'/'false' STRINGS like the ones beside them. // `hasConfiguration` opens the block the stale note sits in, so it is also // 'true' for a line WITHOUT answers that hydration flagged (its product now // requires one): the checkout refuses that line too. const displayConfigurationLines = ` hasConfiguration: item.configurationKey || item.configurationStale === true ? 'true' : 'false', configurationLabel: item.configurationLabel || '', configurationStale: item.configurationStale === true ? 'true' : 'false', configurationFiles: tqConfigurationFiles(item.configuration),` const displayCartItemsCode = regional ? ` // Same projection as a single-rate store's, with each line priced in the // destination's tax by the quote. const displayCartItems = useMemo( () => cartItems.map((item, index) => { const pricing = regionalLinePricing(regionalQuote, item, index) return Object.assign({}, item, {${displayImageLine} unitPrice: formatCartMoney(pricing.unitPrice), price: formatCartMoney(pricing.lineTotal), originalPrice: pricing.originalLineTotal === null ? '' : formatCartMoney(pricing.originalLineTotal), hasDiscount: cartItemHasDiscount(item) ? 'true' : 'false', isRecurring: __deBool(item.isRecurring, false) ? 'true' : 'false', isDigital: __deBool(item.isDigital, false) ? 'true' : 'false',${displayConfigurationLines} }) }), [cartItems, regionalQuote] ) ` : ` const displayCartItems = useMemo( () => cartItems.map((item) => Object.assign({}, item, {${displayImageLine} unitPrice: formatCartMoney(cartItemDisplayPrice(item)), price: formatCartMoney(cartItemLineTotal(item)), // The price this line was marked down FROM. \`originalPrice\` is stored // NET and PER-UNIT by enrichCartItems; here it is grossed up and // multiplied out exactly like \`price\`, so the struck figure and the // charged one are the same kind of number. Shadowing the stored key // matches what \`price\` and \`unitPrice\` already do. originalPrice: cartItemHasDiscount(item) ? formatCartMoney(cartItemOriginalLineTotal(item)) : '', // A 'true'/'false' STRING, because a rendering condition compares // operands as strings and a \`!= ''\` test passes for undefined. hasDiscount: cartItemHasDiscount(item) ? 'true' : 'false', // Same convention: the cart line hides its quantity stepper for a // subscription (one unit, billed by the provider). isRecurring: __deBool(item.isRecurring, false) ? 'true' : 'false', isDigital: __deBool(item.isDigital, false) ? 'true' : 'false',${displayConfigurationLines} }) ), [cartItems] ) ` const shippingOptionsExpression = regional ? 'regionalShippingOptions(regionalQuote)' : '[]' const shippingOptionsVisibleExpression = regional ? "regionalQuote.fulfillment === 'delivery' && regionalQuote.options.length > 1 ? 'true' : 'false'" : "'false'" const shippingStatusExpression = regional ? 'regionalQuote.status' : "'ok'" const codAvailableExpression = regional ? "regionalQuote.codAvailable ? 'true' : 'false'" : "'true'" const settingsExpression = regional ? 'regionalSettingsView(settings, regionalQuote)' : 'settings' const valueExtraDeps = regional ? 'regionalQuote, ' : '' return `import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useRouter } from 'next/router' ${assetUrlsImport}${cartLocaleImport}${ORDER_ATTRIBUTION_WRITER} const CART_STORAGE_KEY = 'workflow_cart' const CART_SETTINGS_STORAGE_KEY = 'workflow_cart_settings' const PICKUP_STORE_DEFAULT_KEY = 'workflow_pickup_store_default' const VOUCHER_STORAGE_KEY = 'workflow_voucher' ${workflowSettingsGlobalCode}${dsConstCode} const EcommerceContext = createContext(null) function loadCartFromStorage() { if (typeof window === 'undefined') return [] try { const raw = localStorage.getItem(CART_STORAGE_KEY) return raw ? JSON.parse(raw) : [] } catch { return [] } } ${ProductDiscounts.generateProductDiscountHelperCode()} ${ProductOptions.generateProductOptionsHelperCode()} ${CartLinePricing.generateCartLinePricingHelperCode()} ${CartCurrency.generateCartCurrencyHelperCode()} ${CART_LINE_OPTIONS_HELPERS} ${generateDiscountProjectionCode()} ${regionalModuleCode} ${discountEngineModuleCode} ${giftCardModuleCode} ${enrichFnCode} ${SUBSCRIPTION_HELPERS} ${PAYMENT_PROVIDER_KIND_FILTER_CODE} function saveCartToStorage(items) { if (typeof window === 'undefined') return try { localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(items)) } catch {} } ${cartDbHelpers} function roundMoney(n) { return Math.round((Number(n) || 0) * 100) / 100 } // Percentage added on top of every stored product price. Baked from the // merchant's invoice settings at export time; \`0\` means "prices are already // what the customer pays" (tax-inclusive pricing, or no tax configured). const STOREFRONT_TAX_RATE = ${storefrontTaxRate} // Gross counterpart of a stored (net) price. Cart lines are persisted to // localStorage and to \`teleport_order_items\` at their NET price — that is the // merchant's source of truth, and the invoice route re-derives VAT from it — // so the tax is added HERE, at every point a price is shown or charged, and // never written back into the stored cart. A rate change therefore re-prices // every existing basket on the next publish, with no migration and no way to // tax the same line twice. function applyStorefrontTax(amount) { const base = Number(amount) || 0 if (STOREFRONT_TAX_RATE <= 0) return base return roundMoney(base * (1 + STOREFRONT_TAX_RATE / 100)) } // The unit price the shopper is quoted, and pays. Rounded per UNIT (not per // line) so \`unit price × quantity\` really does equal the line total the cart // page prints beside it. function cartItemDisplayPrice(item) { return applyStorefrontTax(item && item.price) } // A line's quantity, defended against the shapes a cart can actually hold: the // field arrives as a string from a hand-edited localStorage cart, and a missing // or non-positive value has always meant "one of these". function cartItemQuantity(item) { const qty = Number(item && item.quantity) return isFinite(qty) && qty > 0 ? qty : 1 } // What ONE cart line costs — the gross unit price times its quantity. This is // what the cart page prints beside the \`− qty +\` stepper and what the checkout // summary prints under its "Total" column; summing it across the lines gives // back exactly \`computeCartMeta\`'s subtotal, which is why both go through the // same two helpers. function cartItemLineTotal(item) { return roundMoney(cartItemDisplayPrice(item) * cartItemQuantity(item)) } // Whether this line carries a live scheduled markdown. \`originalPrice\` is // stamped by enrichCartItems as the NET list price the line was discounted // from, and cleared to null the moment the discount expires — so comparing it // with the NET price actually charged is the whole test. Number(null) is 0 and // Number(undefined) is NaN, and neither is greater than a price, so a line that // predates the feature reads as undiscounted. function cartItemHasDiscount(item) { const original = Number(item && item.originalPrice) return isFinite(original) && original > Number(item && item.price) } // The gross line total BEFORE the markdown — the figure struck through beside // what the shopper actually pays. Grossed and multiplied out through the same // two steps as \`cartItemLineTotal\`, or the two numbers would not be comparable. function cartItemOriginalLineTotal(item) { return roundMoney(applyStorefrontTax(item && item.originalPrice) * cartItemQuantity(item)) } // Money reaching a bound text node is printed verbatim, so it is formatted here // or not at all: \`40.46 * 5\` is \`202.3\`, and a price slot has to read // \`202.30\`. function formatCartMoney(amount) { const n = Number(amount) return (isFinite(n) ? n : 0).toFixed(2) } function computeCartMeta(items) { let total = 0 let itemCount = 0 for (let i = 0; i < items.length; i++) { total += cartItemLineTotal(items[i]) itemCount += cartItemQuantity(items[i]) } return { total: roundMoney(total), itemCount } } // Resolve each category's name/description to \`locale\` from its // \`translations\` map, falling back to the main-language (top-level) fields // when there's no override yet, or no locale is active. Recurses into // \`children\` — every node in the tree carries its own translations. function resolveCategoryTranslations(categories, locale) { if (!Array.isArray(categories) || !categories.length) return categories return categories.map(function (category) { var override = locale && category.translations ? category.translations[locale] : null return Object.assign({}, category, { name: (override && override.name) || category.name, description: (override && override.description) || category.description, children: resolveCategoryTranslations(category.children, locale), }) }) } // Mirrors \`resolveCartDelivery\` in the editor (teleport-gui // \`features/e-commerce/utils/delivery-fee.ts\`), which is what the cart // simulator and the canvas projection use — a difference between the two is a // page that previews one delivery fee and charges another. // // \`deliveryEnabled\` gates the RATE, not just the row: a store that switched to // pickup-only keeps whatever \`deliveryPrice\` it last saved, and quoting it // would charge for a delivery that never happens. function computeShippingMeta(cartTotal, deliveryConfig, deliveryEnabled) { const cart = roundMoney(cartTotal) const freeDeliveryEnabled = deliveryConfig?.freeDeliveryEnabled ?? false const threshold = roundMoney(deliveryConfig?.freeDeliveryThreshold ?? 0) const baseDeliveryPrice = deliveryEnabled ? roundMoney(deliveryConfig?.deliveryPrice ?? 0) : 0 // Epsilon defends against IEEE-754 drift on the cart subtotal — accumulated // \`price * quantity\` sums can land at e.g. 4999.999999999 even after // \`roundMoney\`, slipping below the threshold by sub-cent amounts. // Half a cent is well below any currency's smallest unit, so this never // triggers a false free-shipping at meaningful values below the threshold. const shippingIsFree = deliveryConfig === null ? true : freeDeliveryEnabled && (cart + 0.005) >= threshold const shippingPrice = deliveryConfig === null ? 0 : (shippingIsFree ? 0 : baseDeliveryPrice) const totalWithShipping = roundMoney(cart + shippingPrice) const freeDeliveryProgress = deliveryConfig === null || threshold <= 0 ? '100%' : Math.min((cart / threshold) * 100, 100) + '%' const freeDeliveryRemaining = deliveryConfig === null ? 0 : roundMoney(Math.max(0, threshold - cart)) return { shippingIsFree, shippingPrice, totalWithShipping, freeDeliveryProgress, freeDeliveryRemaining } } // The voucher the shopper applied at checkout, kept beside the cart so the // discount survives a navigation and recomputes as they edit their basket. // // Anything unparseable is treated as "no voucher" rather than throwing: this // runs during render, and a malformed value must never take the storefront // down. The stored copy is DISPLAY only — the place-order workflow re-reads the // voucher from the database and prices the order from that. function loadVoucherFromStorage() { if (typeof window === 'undefined') return null try { const raw = localStorage.getItem(VOUCHER_STORAGE_KEY) if (!raw) return null const parsed = JSON.parse(raw) if (!parsed || typeof parsed !== 'object' || !parsed.code) return null return parsed } catch { return null } } export const EcommerceProvider = ({ children }) => { const router = useRouter() const [cartItems, setCartItems] = useState([]) // The lines as of this render, for \`addToCart\` to judge a new line against // synchronously — a refusal has to be answered to the caller, not to the // state updater. const cartItemsRef = useRef([]) cartItemsRef.current = cartItems const [cartMeta, setCartMeta] = useState({ total: 0, itemCount: 0 }) const [isHydrated, setIsHydrated] = useState(false) const [storeLocations, setStoreLocations] = useState([]) const [appliedVoucher, setAppliedVoucher] = useState(null) const enrichRef = useRef(false) useEffect(() => { // First-touch attribution, before anything can navigate away from the // landing URL. Idempotent and cheap on a revisit; the checkout reads it back // and stamps it onto the order. captureOrderAttribution() const items = loadCartFromStorage() setCartItems(items) setCartMeta(computeCartMeta(items)) setAppliedVoucher(loadVoucherFromStorage()) setIsHydrated(true) // Enrich cart items with product details from DB if missing if (!enrichRef.current) { enrichRef.current = true enrichCartItems(items).then(function(enriched) { if (enriched !== items) { setCartItems(enriched) setCartMeta(computeCartMeta(enriched)) saveCartToStorage(enriched) } }) } }, []) useEffect(() => { const syncFromStorage = () => { const items = loadCartFromStorage() setCartItems(items) setCartMeta(computeCartMeta(items)) // Enrich on sync too enrichCartItems(items).then(function(enriched) { if (enriched !== items) { setCartItems(enriched) setCartMeta(computeCartMeta(enriched)) saveCartToStorage(enriched) } }) } const onStorageChange = (e) => { if (e.key === CART_STORAGE_KEY) syncFromStorage() if (e.key === VOUCHER_STORAGE_KEY) setAppliedVoucher(loadVoucherFromStorage()) } // The apply/remove voucher workflows write localStorage and fire this, so // the order summary updates the moment a code is accepted rather than on // the next unrelated render. const syncVoucher = () => setAppliedVoucher(loadVoucherFromStorage()) window.addEventListener('teleport:cart-changed', syncFromStorage) window.addEventListener('teleport:voucher-changed', syncVoucher) window.addEventListener('storage', onStorageChange) return () => { window.removeEventListener('teleport:cart-changed', syncFromStorage) window.removeEventListener('teleport:voucher-changed', syncVoucher) window.removeEventListener('storage', onStorageChange) } }, []) ${cartDbMountEffect}${cartDbPersistEffect} ${ fetchStoreLocations ? ` useEffect(() => { fetch('/api/ecommerce/store-locations') .then((res) => res.ok ? res.json() : { locations: [] }) .then((data) => setStoreLocations(data.locations || [])) .catch(() => setStoreLocations([])) }, []) ` : '' } // First store id, used as the default pickup-location selection on the // checkout page. The checkout page picks this up via the // \`workflow_pickup_store_default\` localStorage key (the page-load // workflow reads it) and via \`ecommerce.defaultPickupStoreId\` directly. const defaultPickupStoreId = useMemo(() => { if (!storeLocations || storeLocations.length === 0) return '' const first = storeLocations[0] return (first && (first.id || first._id)) || '' }, [storeLocations]) // Capture the PayPal order on the buyer's return from PayPal's hosted // checkout. PayPal redirects with \`?payment=success&token={paypalOrderId}\` // on the URL; without an explicit \`POST /v2/checkout/orders/{id}/capture\` // call, PayPal leaves the order in APPROVED state, never moves the funds, // and never fires \`PAYMENT.CAPTURE.COMPLETED\`. Done in EcommerceProvider // (rather than only in the order-details page-load workflow) so projects // pick up the fix without a UIDL re-export — the per-page workflow step // is a stronger guarantee for projects that DO re-export. The capture // endpoint is idempotent (PayPal returns ORDER_ALREADY_CAPTURED on a // second call, which our handler treats as success), so double-fire is // safe. const paypalCaptureRef = useRef(new Set()) useEffect(() => { if (typeof window === 'undefined') return try { var search = String(window.location.search || '') if (!search) return if (search.charAt(0) === '=') return // Manual parse — avoids an URLSearchParams polyfill on older runtimes. var paymentSuccess = false var paypalOrderId = '' // PayPal returns an approved ORDER with \`PayerID\` beside the token; a // subscription approval (or another provider's return) carries none. var paypalPayer = '' // A subscription approval returns its id (\`I-…\`) instead. var paypalSubscriptionId = '' var stripped = search.charAt(0) === '?' ? search.slice(1) : search var parts = stripped.split('&') for (var pi = 0; pi < parts.length; pi++) { var eq = parts[pi].indexOf('=') var k = eq >= 0 ? parts[pi].slice(0, eq) : parts[pi] var v = eq >= 0 ? parts[pi].slice(eq + 1) : '' try { k = decodeURIComponent(k) } catch (_e) {} try { v = decodeURIComponent(v) } catch (_e) {} if (k === 'payment' && v === 'success') paymentSuccess = true else if (k === 'token' && v) paypalOrderId = v else if (k === 'PayerID' && v) paypalPayer = v else if (k === 'subscription_id' && v) paypalSubscriptionId = v } if (!paymentSuccess) return // The route captures an order and settles what PayPal took (a // subscription: its mandate and first payment) from PayPal's own answer. var paypalReturn = paypalSubscriptionId ? { key: paypalSubscriptionId, body: { subscriptionId: paypalSubscriptionId } } : paypalOrderId && paypalPayer ? { key: paypalOrderId, body: { orderId: paypalOrderId } } : null if (!paypalReturn || paypalCaptureRef.current.has(paypalReturn.key)) return paypalCaptureRef.current.add(paypalReturn.key) fetch('/api/ecommerce/paypal/capture', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(paypalReturn.body), }).catch(function () { // Fire-and-forget from the buyer's perspective: PayPal already sent // them back with success, and PayPal's webhook settles the payment // too; a failure here doesn't change what the buyer sees. }) } catch (_e) {} }, []) useEffect(() => { if (typeof window === 'undefined') return try { if (defaultPickupStoreId) { localStorage.setItem(PICKUP_STORE_DEFAULT_KEY, String(defaultPickupStoreId)) // Notify any listening workflow (specifically the checkout page's // "default pickup store on locations loaded" handler) that the // default is now available. Needed because the page-load workflow // races the async store-locations fetch — on hard refresh the // localStorage key is empty when page-load fires, so we have to // wake the workflow up after the fetch finishes. window.dispatchEvent( new CustomEvent('workflow:custom:teleport-store-locations-loaded', { detail: { defaultId: String(defaultPickupStoreId) }, }) ) } else { localStorage.removeItem(PICKUP_STORE_DEFAULT_KEY) } } catch {} }, [defaultPickupStoreId]) const maxQtyPerProduct = ${maxQtyLiteral === null ? 'null' : maxQtyLiteral} // Persist cart settings so workflow handlers (cart-add-item, // cart-update-item-quantity, cart-get-total) can enforce the same cap and // shipping math as the React context. Those handlers run as standalone // localStorage mutators with no access to React context, so the only shared // channel is localStorage itself. We mirror BOTH the quantity cap and the // delivery config: cart-get-total reads deliveryConfig at runtime so the // place-order assemble script charges the CURRENT shipping price instead // of values that were baked into the workflow at last UIDL re-export. // The deliveryConfig literal is the build-time snapshot — same source as // the \`settings\` memo below — emitted up here so this hook doesn't run // before \`settings\` is initialised (TDZ). useEffect(() => { if (typeof window === 'undefined') return try { // The shopper context (\`customer\`) is the checkout page-load workflow's, // not this provider's: it is carried over, never wiped. Every feature // snapshot (regional, discounts, gift cards) IS wiped here and merged // back by its own effect right after — which is what makes a feature the // merchant turned off disappear from the mirror. let customer = null try { const previous = JSON.parse(localStorage.getItem(CART_SETTINGS_STORAGE_KEY) || 'null') if (previous && typeof previous === 'object' && previous.customer && typeof previous.customer === 'object') { customer = previous.customer } } catch { customer = null } localStorage.setItem( CART_SETTINGS_STORAGE_KEY, JSON.stringify({ maxQuantityPerProduct: maxQtyPerProduct, deliveryConfig: ${deliveryConfigJson}, // Same channel, same reason as deliveryConfig: cart-get-total runs as // a standalone localStorage handler with no access to this context, // and the amount it hands to the place-order workflow has to be the // amount this provider just showed the buyer. taxConfig: { storefrontTaxRate: STOREFRONT_TAX_RATE }, // Whether a stored voucher is to be honoured at all — the same gate // the discount engine applies here. vouchersEnabled: ${ecommerceSettings.vouchersEnabled === true}, // Whether recurring products can be sold, and by which providers: // the checkout's page-load workflow pre-selects the first for a // cart holding a subscription. subscriptions: ${subscriptionsMirrorJson}, // Which cart kinds each provider may be paid for. paymentKinds: ${paymentKindsMirrorJson}, customer: customer, }) ) } catch {} }, [maxQtyPerProduct]) // Answers \`{ added, reason?, message? }\` like the add-to-cart workflow node: // a line whose kind (subscription / digital / physical) differs from the // cart's, or a second subscription, is refused so one order never holds two // kinds of fulfilment. The kind comes from the line handed in (the product // row's \`payment_type\` / \`is_digital\`), never from a later fetch. A line // priced in another currency than the cart's is refused too, and the line // keeps the currency it was handed so the next add is checked against it // before the enrichment pass re-reads the product. // // The chosen product options (\`configuration\`) are part of the line: the same // product with other answers is another line. Such a line is priced by the // enrichment pass right after, from the product's own option definitions — // never from a surcharge the caller states. const addToCart = useCallback((input) => { const item = tqWithLineConfiguration(input) const refusal = tqCartKindRefusal(cartItemsRef.current, item) if (refusal) { return refusal } setCartItems((prev) => { const existing = prev.find((i) => tqSameCartLine(i, item)) const currentQty = existing ? existing.quantity : 0 let desiredQty = currentQty + (item.quantity || 1) if (maxQtyPerProduct !== null && desiredQty > maxQtyPerProduct) { desiredQty = maxQtyPerProduct } if (desiredQty === currentQty && existing) { return prev } let next if (existing) { next = prev.map((i) => (tqSameCartLine(i, item) ? { ...i, quantity: desiredQty } : i)) } else { // A configured line's id names its options too: two configurations of // one product added in the same millisecond stay two ids. const line = { id: item.id || (item.productId + (item.configurationKey ? '__' + item.configurationKey : '') + '_' + Date.now()), productId: item.productId, variantId: item.variantId || null, quantity: desiredQty, price: item.price || 0, name: item.name || '', image: item.image || null, } if (item.currency) { line.currency = item.currency line.currencySymbol = item.currencySymbol || null } if (item.configurationKey) { line.configuration = item.configuration line.configurationKey = item.configurationKey } next = [...prev, line] } setCartMeta(computeCartMeta(next)) saveCartToStorage(next) if (item.configurationKey) { // Applied only to the cart it priced: a cart changed meanwhile keeps // its lines, and the line is priced by the next hydration instead. enrichCartItems(next).then(function (enriched) { if (enriched === next) return setCartItems(function (current) { if (current !== next) return current setCartMeta(computeCartMeta(enriched)) saveCartToStorage(enriched) return enriched }) }) } return next }) }, [maxQtyPerProduct]) const removeFromCart = useCallback((itemId) => { setCartItems((prev) => { const next = prev.filter((i) => i.id !== itemId) setCartMeta(computeCartMeta(next)) saveCartToStorage(next) return next }) }, []) const updateItemQuantity = useCallback((itemId, quantity) => { setCartItems((prev) => { let clampedQty = quantity if (maxQtyPerProduct !== null && clampedQty > maxQtyPerProduct) { clampedQty = maxQtyPerProduct } let next if (clampedQty <= 0) { next = prev.filter((i) => i.id !== itemId) } else { next = prev.map((i) => (i.id === itemId ? { ...i, quantity: clampedQty } : i)) } setCartMeta(computeCartMeta(next)) saveCartToStorage(next) return next }) }, [maxQtyPerProduct]) const clearCart = useCallback(() => { const next = [] setCartItems(next) setCartMeta({ total: 0, itemCount: 0 }) saveCartToStorage(next) }, []) const settings = useMemo(() => (${settingsJson}), []) const allPaymentProviders = useMemo(() => (${paymentProvidersJson}), []) const paymentProviders = useMemo( () => tqProvidersForCart(allPaymentProviders, cartItems), [allPaymentProviders, cartItems] ) const subscriptionPaymentProviders = useMemo( () => tqProvidersForCart(allPaymentProviders.filter((provider) => provider.supportsSubscriptions), cartItems), [allPaymentProviders, cartItems] ) const ecommerceCategoriesRaw = useMemo(() => (${ecommerceCategoriesJson}), []) const ecommerceCategories = useMemo( () => resolveCategoryTranslations(ecommerceCategoriesRaw, router.locale), [ecommerceCategoriesRaw, router.locale] ) // The subscription in the basket (at most one) and whether every line is a // download. Read BEFORE the pricing below: a digital-only cart ships nothing, // so its shipping is priced at zero rather than quoted and waived. const recurringLine = useMemo(() => cartRecurringLine(cartItems), [cartItems]) const recurringTrial = recurringLine !== null && tqPositiveCount(recurringLine.trialDays) !== null const digitalOnly = useMemo(() => isDigitalOnlyCart(cartItems), [cartItems]) const hasGiftCardLines = useMemo(() => cartHasGiftCardLine(cartItems), [cartItems]) ${pricingCode}${discountEngineProviderCode}${giftCardProviderCode}${generateDiscountChoiceProviderCode()} // Every discount on the basket — the applied voucher and the automatic rules // — priced by the shared engine over the same lines the summary prints. ${discountMetaCode} // What the cart & checkout pages bind their per-line money to. The stored // \`cartItems\` keep the NET unit price (they are what gets persisted and what // becomes \`teleport_order_items.unit_price\`); this projection is the only // place the tax is folded in for display, so the money a shopper reads always // agrees with the subtotal computed by \`computeCartMeta\`. // // \`price\` is the LINE total, not the unit price: a cart line is drawn next to // its own quantity stepper (and the checkout summary heads the column // "Total"), so the amount that belongs there is what those units cost // together. \`unitPrice\` keeps the per-unit figure addressable for a template // that wants to spell out "x each". ${displayCartItemsCode} // Derive currency symbol from the first cart item or fallback to '$' const cartCurrencySymbol = useMemo(() => { for (let ci = 0; ci < cartItems.length; ci++) { if (cartItems[ci].currencySymbol) return cartItems[ci].currencySymbol if (cartItems[ci].currency) { var sym = { USD: '$', EUR: '\u20ac', GBP: '\u00a3', JPY: '\u00a5' } return sym[cartItems[ci].currency] || cartItems[ci].currency } } return '$' }, [cartItems]) // A free-shipping discount waives the delivery fee, so every figure derived // from shipping has to use the WAIVED amount — otherwise the summary shows // "FREE" next to a total that still includes the fee. The engine waives at // most the fee itself, and at most once across every rule. const effectiveShippingPrice = Math.max( 0, roundMoney(shippingMeta.shippingPrice - discountMeta.shippingDiscount) ) // What a delivered order costs, every discount taken, never below zero; and // what a collected one owes — no delivery, and the discounts the engine // applies to an order without a shipping fee (\`pickupDiscountMeta\`), which // is what the place-order workflow charges for it. const effectiveTotal = Math.max( 0, roundMoney(cartGoodsTotal + effectiveShippingPrice - discountMeta.goodsDiscount) ) const pickupTotal = Math.max(0, roundMoney(cartGoodsTotal - pickupDiscountMeta.goodsDiscount)) // What a gift card pays of each of those, and what is left to charge. ${giftCardMetaCode} const value = useMemo(() => ({ Cart: { items: displayCartItems, total: cartGoodsTotal, itemCount: cartMeta.itemCount, shippingPrice: effectiveShippingPrice, shippingIsFree: discountMeta.shippingDiscount > 0 ? true : shippingMeta.shippingIsFree, totalWithShipping: effectiveTotal, freeDeliveryProgress: shippingMeta.freeDeliveryProgress, freeDeliveryRemaining: shippingMeta.freeDeliveryRemaining, maxQuantityPerProduct: maxQtyPerProduct, // Aliases used by UIDL cart/checkout templates. // // Every one of these is 2-DECIMAL FORMATTED, for the same reason each cart // line is: a bound text node prints what it is given, verbatim. Left raw, // a £12.50 subtotal renders "12.5" beside per-line prices that do say // "12.50", and the editor canvas (which formats these through // \`resolveCartDerivedFieldFromSnapshot\`) disagrees with the published // page. Nothing computes with them — they are read only by text bindings. rawSubtotal: formatCartMoney(cartGoodsTotal), // Discount-inclusive and never below zero. Built from the WAIVED shipping // so a free-shipping discount reduces the total exactly once. rawTotal: formatCartMoney(effectiveTotal), // What a pickup order owes: no delivery, but the discounts still apply. rawSubtotalAfterDiscount: formatCartMoney(pickupTotal), rawShipping: formatCartMoney(effectiveShippingPrice), // Voucher surface. The flags are 'true'/'false' STRINGS because a // rendering condition cannot gate on a formatted money value. rawDiscount: formatCartMoney(discountMeta.rawDiscount), voucherApplied: discountMeta.voucherApplied, voucherCode: discountMeta.voucherCode, voucherFreeShipping: discountMeta.voucherFreeShipping, voucherDiscountVisible: discountMeta.voucherDiscountVisible, // Automatic (code-less) discounts: what they took off the goods, which // rules did, and whether the summary row has anything to show. A store // without the engine's rule feed reports none. automaticDiscount: formatCartMoney(discountMeta.automaticDiscount), automaticDiscountVisible: discountMeta.automaticDiscountVisible, automaticDiscountLabel: discountMeta.automaticDiscountLabel, // Everything taken off the goods, voucher and automatic rules together. discountTotal: formatCartMoney(discountMeta.goodsDiscount), // The sets the shopper may choose between when several discounts could // apply but not together (an exclusive discount alone, or the combinable // ones together), and whether the checkout's chooser has anything to // show. Each option: \`{ key, label, saving, selected }\`. discountOptions: formatDiscountOptions(discountMeta.discountOptions), discountOptionsVisible: discountMeta.discountOptionsVisible, // The same discount surface for a collected order, priced like its total // (\`pickupDiscountMeta\`): with no delivery fee to waive, a free-shipping // rule steps aside — an exclusive one no longer suppresses the rules and // voucher after it — and no voucher reads as free shipping. The checkout // swaps these in on its own pickup state, so the rows a pickup summary // prints add up to \`rawSubtotalAfterDiscount\`. rawDiscountPickup: formatCartMoney(pickupDiscountMeta.rawDiscount), voucherFreeShippingPickup: pickupDiscountMeta.voucherFreeShipping, voucherDiscountVisiblePickup: pickupDiscountMeta.voucherDiscountVisible, automaticDiscountPickup: formatCartMoney(pickupDiscountMeta.automaticDiscount), automaticDiscountVisiblePickup: pickupDiscountMeta.automaticDiscountVisible, automaticDiscountLabelPickup: pickupDiscountMeta.automaticDiscountLabel, discountTotalPickup: formatCartMoney(pickupDiscountMeta.goodsDiscount), discountOptionsPickup: formatDiscountOptions(pickupDiscountMeta.discountOptions), discountOptionsVisiblePickup: pickupDiscountMeta.discountOptionsVisible, // Gift-card tender: never subtracted from the totals above — it is how // part of the total gets PAID — so the amount due is its own figure, for // each fulfilment shape the checkout page can be in. giftCardApplied: giftCardMeta.giftCardApplied, giftCardLast4: giftCardMeta.giftCardLast4, giftCardAmount: giftCardMeta.giftCardAmount, giftCardAmountPickup: giftCardMeta.giftCardAmountPickup, amountDue: giftCardMeta.amountDue, amountDuePickup: giftCardMeta.amountDuePickup, // Shipping zones. A store without them always reports one delivery // price, nothing pending and cash on delivery allowed, so a checkout page // built with the shipping-method list simply never shows it. shippingOptions: ${shippingOptionsExpression}, shippingOptionsVisible: ${shippingOptionsVisibleExpression}, shippingStatus: ${shippingStatusExpression}, codAvailable: ${codAvailableExpression}, // Subscriptions and digital delivery. Flags are 'true'/'false' STRINGS // for the checkout's gates; \`recurringSummary\` is the sentence the // summary prints ("Billed every month · first charge today") and // \`recurringAmount\` what each cycle charges — the delivered total, // because a recurring cart takes no store pickup. hasRecurringLines: recurringLine ? 'true' : 'false', // A subscription whose first charge is a free trial: nothing for a gift // card to pay, so the checkout hides the card entry. hasRecurringTrial: recurringTrial ? 'true' : 'false', isDigitalOnly: digitalOnly ? 'true' : 'false', // A cart buying a gift card: no code and no card can apply to it, so the // checkout hides both entries. hasGiftCardLines: hasGiftCardLines ? 'true' : 'false', recurringSummary: recurringLine ? cartRecurringSummary(recurringLine) : '', recurringAmount: formatCartMoney(effectiveTotal), currencySymbol: cartCurrencySymbol, addToCart, removeFromCart, updateItemQuantity, clearCart, isHydrated, }, Settings: ${settingsExpression}, paymentProviders, hasPaymentProviders: ${hasPaymentProviders}, // The providers a recurring cart is offered instead of \`paymentProviders\`; // both lists hold only the providers that may take the cart as it is. subscriptionPaymentProviders, storeLocations, defaultPickupStoreId, ecommerceCategories, // \`discountMeta\`, \`pickupDiscountMeta\`, \`giftCardMeta\` and the figures // derived from them MUST be listed. Applying or removing a code changes // nothing else in this array — the cart items, the cart meta and the shipping // meta are all untouched — so without them the memo returned the previous // context object, the Provider's value stayed reference-identical, and the // summary only caught up on a page reload. }), [displayCartItems, cartMeta, ${valueExtraDeps}shippingMeta, discountMeta, pickupDiscountMeta, giftCardMeta, effectiveShippingPrice, effectiveTotal, pickupTotal, recurringLine, digitalOnly, maxQtyPerProduct, cartCurrencySymbol, addToCart, removeFromCart, updateItemQuantity, clearCart, isHydrated, settings, paymentProviders, subscriptionPaymentProviders, storeLocations, defaultPickupStoreId, ecommerceCategories]) return ( {children} ) } export const useEcommerce = () => { const context = useContext(EcommerceContext) if (!context) { throw new Error('useEcommerce must be used within an EcommerceProvider') } return context } export const useCart = () => { const { Cart } = useEcommerce() return Cart } export const useEcommerceSettings = () => { const { Settings } = useEcommerce() return Settings } ` } /** * Percentage the storefront adds on top of every stored product price, or `0` * when prices are already tax-inclusive / no rate is configured. * * The rule itself lives in `@teleporthq/teleport-shared` so the data-source * transform (a package this one depends ON, not the other way round) applies * the same gate to catalogue prices. Re-exported here because this module is * where the export side has always read it from: the provider bakes the result * into `STOREFRONT_TAX_RATE` and mirrors it into * `workflow_cart_settings.taxConfig`, which is where the standalone * `cart-get-total` handler reads it at runtime. */ export const resolveStorefrontTaxRate = StorefrontTax.resolveStorefrontTaxRate function buildSettingsObject( ecommerceSettings: UIDLEcommerceSettings, invoiceSettings?: UIDLInvoiceSettings ): Record { const settings: Record = { cashOnDelivery: ecommerceSettings.cashOnDelivery, deliveryEnabled: ecommerceSettings.deliveryEnabled, storePickupEnabled: ecommerceSettings.storePickupEnabled, guestCheckout: ecommerceSettings.guestCheckout, stockManagement: ecommerceSettings.stockManagement, orderNotifications: ecommerceSettings.orderNotifications, // This object IS `E-commerce.Settings` on the storefront, so every flag a // generated page gates on has to be here. The checkout's voucher block // renders on `Settings.vouchersEnabled`, and the provider only reads a // stored voucher when it is true — omitting it hid the input on every // store and silently disabled the discount. vouchersEnabled: ecommerceSettings.vouchersEnabled === true, // The discount engine and gift cards, by the same rule: present on the // Settings object the checkout page gates its rows and blocks on. Both are // "the UIDL carries the block" — a checkout page built with the feature — // so a page never shows a block its own workflows were not built with. discountEngineEnabled: ecommerceSettings.discountEngine?.enabled === true, automaticDiscountsEnabled: ecommerceSettings.discountEngine?.enabled === true && ecommerceSettings.discountEngine.automaticDiscounts === true, giftCardsEnabled: ecommerceSettings.giftCards?.enabled === true, // Same rule again. A recurring product's buy controls are gated on // `subscriptionsEnabled`: a checkout built before subscriptions would // charge it once, so an older storefront hides them instead. subscriptionsEnabled: ecommerceSettings.subscriptions?.enabled === true, digitalProductsEnabled: ecommerceSettings.digitalProducts?.enabled === true, } if (ecommerceSettings.deliveryConfig) { settings.Delivery = { deliveryPrice: ecommerceSettings.deliveryConfig.deliveryPrice, freeDeliveryEnabled: ecommerceSettings.deliveryConfig.freeDeliveryEnabled, freeDeliveryThreshold: ecommerceSettings.deliveryConfig.freeDeliveryThreshold, estimatedDeliveryDays: ecommerceSettings.deliveryConfig.estimatedDeliveryDays, allowDeliveryNotes: ecommerceSettings.deliveryConfig.allowDeliveryNotes, } } else { settings.Delivery = null } if (ecommerceSettings.stockManagementConfig) { settings.Stock = { allowBackorders: ecommerceSettings.stockManagementConfig.allowBackorders, lowStockThreshold: ecommerceSettings.stockManagementConfig.lowStockThreshold, lowStockAlerts: ecommerceSettings.stockManagementConfig.lowStockAlerts, outOfStockVisibility: ecommerceSettings.stockManagementConfig.outOfStockVisibility, maxQuantityPerProduct: ecommerceSettings.stockManagementConfig.maxQuantityPerProduct ?? null, } } else { settings.Stock = null } if (invoiceSettings) { settings.Invoices = { enabled: invoiceSettings.enabled, invoicePrefix: invoiceSettings.invoicePrefix, defaultTaxRate: invoiceSettings.defaultTaxRate, taxIncludedInPrice: invoiceSettings.taxIncludedInPrice, showDiscount: invoiceSettings.showDiscount, autoGenerateOnPayment: invoiceSettings.autoGenerateOnPayment, Company: { companyName: invoiceSettings.companyDetails.companyName, companyAddress: invoiceSettings.companyDetails.companyAddress, companyCity: invoiceSettings.companyDetails.companyCity, companyState: invoiceSettings.companyDetails.companyState, companyZip: invoiceSettings.companyDetails.companyZip, companyCountry: invoiceSettings.companyDetails.companyCountry, companyVat: invoiceSettings.companyDetails.companyVat, companyRegNumber: invoiceSettings.companyDetails.companyRegNumber, companyEmail: invoiceSettings.companyDetails.companyEmail, companyPhone: invoiceSettings.companyDetails.companyPhone, companyWebsite: invoiceSettings.companyDetails.companyWebsite, }, } } else { settings.Invoices = null } return settings }