{
  "name": "Recover abandoned Shopify checkouts with personalized Zvid videos",
  "nodes": [
    {
      "parameters": {},
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        50,
        240
      ],
      "id": "zvid-sac-0001",
      "name": "Test manually"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        310,
        240
      ],
      "id": "zvid-sac-0002",
      "name": "Every 15 minutes"
    },
    {
      "parameters": {
        "mode": "raw",
        "jsonOutput": "{\n  \"editorUrl\": \"https://editor.zvid.io\",\n  \"shopDomain\": \"your-store\",\n  \"shopifyApiVersion\": \"2026-07\",\n  \"checkoutQuery\": \"recovery_state:not_recovered status:open\",\n  \"scanLimit\": 20,\n  \"recoveryWaitMinutes\": 60,\n  \"followUpEnabled\": true,\n  \"followUpWaitMinutes\": 720,\n  \"skipIfShopifyAlreadyEmailed\": true,\n  \"lockTtlHours\": 48,\n  \"brandName\": \"YOUR BRAND\",\n  \"storeUrl\": \"your-store.myshopify.com\",\n  \"brandBackground\": \"#12100E\",\n  \"brandAccent\": \"#E2714B\",\n  \"accentSoft\": \"#F0B08C\",\n  \"creamColor\": \"#F6F1E9\",\n  \"mutedColor\": \"#9C948A\",\n  \"headingFont\": \"Sora\",\n  \"accentFont\": \"Fraunces\",\n  \"uiFont\": \"DM Sans\",\n  \"currencySymbol\": \"$\",\n  \"hookLine\": \"you left something behind.\",\n  \"hookNote\": \"Your cart is still saved - for now.\",\n  \"cartKicker\": \"STILL IN YOUR CART\",\n  \"cartNote\": \"We saved your cart. Nothing stays reserved forever.\",\n  \"totalLabel\": \"YOUR CART TOTAL\",\n  \"ctaText\": \"Complete your order\",\n  \"ctaSubline\": \"Pick up exactly where you left off.\",\n  \"resolution\": \"instagram-reel\",\n  \"frameRate\": 30,\n  \"musicUrl\": \"\",\n  \"musicVolume\": 0.16,\n  \"dryRun\": false,\n  \"sendEmail\": false,\n  \"emailFrom\": \"hello@your-store.com\",\n  \"emailSubject\": \"{firstName}, your cart is still saved\",\n  \"emailIntro\": \"We kept your cart exactly where you left it. Here is a quick look at what is waiting.\",\n  \"emailButtonText\": \"Complete your order\",\n  \"followUpSubject\": \"{firstName}, one last look at your saved cart\",\n  \"followUpIntro\": \"Your saved checkout is still open. Complete it while the products are available.\",\n  \"pollSeconds\": 10,\n  \"timeoutMinutes\": 20,\n  \"manualFixture\": true,\n  \"testWaitSeconds\": 2,\n  \"testFollowUpInFixture\": true,\n  \"testFollowUpSeconds\": 2,\n  \"testFixtureEligible\": true,\n  \"testFixtureRecoveredBeforeFollowUp\": true,\n  \"testFixture\": {\n    \"checkoutId\": \"gid://shopify/AbandonedCheckout/TEST\",\n    \"checkoutName\": \"#TEST\",\n    \"firstName\": \"Alex\",\n    \"email\": \"alex@example.com\",\n    \"phone\": \"+15555550123\",\n    \"recoveryUrl\": \"https://your-store.myshopify.com\",\n    \"totalPrice\": \"35.00\",\n    \"items\": [\n      {\n        \"title\": \"Clay Pocket Oversized Tee\",\n        \"variantTitle\": \"M\",\n        \"quantity\": 1,\n        \"price\": \"35.00\",\n        \"imageUrl\": \"https://cdn.zvid.io/blog/shopify-abandoned-checkout-videos/bae3f3127eb6/product-source-clay-hero.png\"\n      }\n    ]\n  }\n}",
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        570,
        240
      ],
      "id": "zvid-sac-0003",
      "name": "Config"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "zvid-sac-0004-condition",
              "leftValue": "={{ $('Config').first().json.manualFixture === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1050,
        240
      ],
      "id": "zvid-sac-0004",
      "name": "Use manual fixture?"
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\nconst fixture = cfg.testFixture || {};\nconst items = Array.isArray(fixture.items) ? fixture.items : [];\nif (!items.length || !items.some((item) => String(item.title || '').trim())) {\n  throw new Error('Config.testFixture needs at least one item with a title.');\n}\nreturn [{ json: {\n  found: true,\n  isFixture: true,\n  source: 'fixture',\n  checkoutId: String(fixture.checkoutId || 'gid://shopify/AbandonedCheckout/TEST'),\n  checkoutName: String(fixture.checkoutName || '#TEST'),\n  firstName: String(fixture.firstName || 'Alex').slice(0, 24),\n  email: String(fixture.email || 'alex@example.com'),\n  phone: String(fixture.phone || ''),\n  checkoutUrl: String(fixture.recoveryUrl || 'https://your-store.myshopify.com'),\n  totalPrice: String(fixture.totalPrice || ''),\n  items: items.slice(0, 3).map((item) => ({\n    title: String(item.title || '').trim(),\n    variantTitle: String(item.variantTitle || '').trim(),\n    quantity: Math.max(1, Number(item.quantity || 1)),\n    price: String(item.price || '').trim(),\n    imageUrl: /^https?:\\/\\//i.test(String(item.imageUrl || '')) ? String(item.imageUrl) : '',\n  })),\n  cartRef: 'fixture-' + Date.now(),\n  detectedAt: new Date().toISOString(),\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1310,
        240
      ],
      "id": "zvid-sac-0005",
      "name": "Create safe test checkout"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://' + $('Config').first().json.shopDomain + '.myshopify.com/admin/api/' + $('Config').first().json.shopifyApiVersion + '/graphql.json' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: \"query AbandonedCheckoutCandidates($first: Int!, $query: String!) {\\n  abandonedCheckouts(first: $first, query: $query, sortKey: CREATED_AT, reverse: true) {\\n    pageInfo { hasNextPage endCursor }\\n    nodes {\\n      id name createdAt updatedAt completedAt abandonedCheckoutUrl\\n      customer {\\n        id displayName firstName\\n        defaultEmailAddress { emailAddress }\\n        defaultPhoneNumber { phoneNumber }\\n      }\\n      totalPriceSet { shopMoney { amount currencyCode } }\\n      lineItems(first: 5) {\\n        nodes {\\n          id title variantTitle quantity\\n          image { url altText }\\n          product { id title handle status }\\n          variant { id title availableForSale sellableOnlineQuantity price }\\n          discountedTotalPriceSet { shopMoney { amount currencyCode } }\\n        }\\n      }\\n    }\\n  }\\n}\", variables: { first: Math.min(50, Math.max(1, Number($('Config').first().json.scanLimit || 20))), query: String($('Config').first().json.checkoutQuery || 'recovery_state:not_recovered status:open') } }) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          },
          "timeout": 60000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1570,
        240
      ],
      "id": "zvid-sac-0006",
      "name": "Find open abandoned checkouts",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\nconst response = $input.first().json || {};\nconst status = Number(response.statusCode || 0);\nconst body = response.body === undefined ? response : response.body;\nif (!status) throw new Error('Shopify returned no HTTP response. Check shopDomain and the Header Auth credential.');\nif (status !== 200) {\n  const detail = JSON.stringify(body).slice(0, 700);\n  if (status === 401) {\n    throw new Error('Shopify rejected the access token (HTTP 401). Use the returned access_token, not the Client ID or Client secret. Also set Config.shopDomain to the canonical subdomain shown in Shopify Settings -> Domains; an Admin URL handle or redirecting alias can drop X-Shopify-Access-Token. Response: ' + detail);\n  }\n  throw new Error('Shopify abandoned checkout query failed (HTTP ' + status + '): ' + detail);\n}\nif (Array.isArray(body.errors) && body.errors.length) throw new Error('Shopify GraphQL error: ' + body.errors.map((error) => error.message).join('; '));\nconst connection = body.data && body.data.abandonedCheckouts;\nconst checkouts = connection && Array.isArray(connection.nodes) ? connection.nodes : [];\nconst clean = (value) => String(value == null ? '' : value).replace(/\\s+/g, ' ').trim();\nconst store = $getWorkflowStaticData('global');\nif (!Array.isArray(store.handledCheckoutIds)) store.handledCheckoutIds = [];\nif (!store.inFlightCheckouts || typeof store.inFlightCheckouts !== 'object') store.inFlightCheckouts = {};\nconst handled = new Set(store.handledCheckoutIds.map(String));\nconst now = Date.now();\nconst lockTtl = Math.max(1, Number(cfg.lockTtlHours || 48)) * 3600000;\nfor (const [id, timestamp] of Object.entries(store.inFlightCheckouts)) {\n  if (!Number(timestamp) || now - Number(timestamp) > lockTtl) delete store.inFlightCheckouts[id];\n}\nconst skipped = [];\nlet selected = null;\nfor (const checkout of checkouts) {\n  const customer = checkout.customer || {};\n  const email = clean(customer.defaultEmailAddress && customer.defaultEmailAddress.emailAddress);\n  const lines = checkout.lineItems && Array.isArray(checkout.lineItems.nodes) ? checkout.lineItems.nodes : [];\n  let reason = null;\n  if (checkout.completedAt) reason = 'already completed';\n  else if (!email.includes('@')) reason = 'missing customer email';\n  else if (!clean(checkout.abandonedCheckoutUrl).startsWith('http')) reason = 'missing recovery URL';\n  else if (!lines.length) reason = 'no line items';\n  else if (handled.has(String(checkout.id))) reason = 'already handled by this workflow';\n  else if (store.inFlightCheckouts[String(checkout.id)]) reason = 'another execution is already waiting on it';\n  if (reason) { skipped.push({ checkoutId: checkout.id, checkoutName: checkout.name, reason }); continue; }\n  selected = checkout;\n  break;\n}\nif (!selected) return [{ json: { found: false, source: 'shopify', checked: checkouts.length, skipped, morePages: Boolean(connection && connection.pageInfo && connection.pageInfo.hasNextPage) } }];\nstore.inFlightCheckouts[String(selected.id)] = now;\nconst customer = selected.customer || {};\nconst money = selected.totalPriceSet && selected.totalPriceSet.shopMoney || {};\nconst lines = selected.lineItems.nodes.slice(0, 3);\nreturn [{ json: {\n  found: true,\n  isFixture: false,\n  source: 'shopify',\n  checkoutId: selected.id,\n  checkoutName: selected.name,\n  firstName: clean(customer.firstName || clean(customer.displayName).split(' ')[0]).slice(0, 24),\n  email: clean(customer.defaultEmailAddress && customer.defaultEmailAddress.emailAddress),\n  phone: clean(customer.defaultPhoneNumber && customer.defaultPhoneNumber.phoneNumber),\n  checkoutUrl: clean(selected.abandonedCheckoutUrl),\n  totalPrice: clean(money.amount),\n  currencyCode: clean(money.currencyCode),\n  items: lines.map((line) => {\n    const lineMoney = line.discountedTotalPriceSet && line.discountedTotalPriceSet.shopMoney || {};\n    return {\n      title: clean(line.title || (line.product && line.product.title)),\n      variantTitle: clean(line.variantTitle || (line.variant && line.variant.title)),\n      quantity: Math.max(1, Number(line.quantity || 1)),\n      price: clean(lineMoney.amount || (line.variant && line.variant.price)),\n      imageUrl: clean(line.image && line.image.url),\n      productId: line.product && line.product.id,\n      variantId: line.variant && line.variant.id,\n    };\n  }),\n  cartRef: 'checkout-' + String(selected.id).split('/').pop(),\n  detectedAt: new Date().toISOString(),\n  checked: checkouts.length,\n  skipped,\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1830,
        240
      ],
      "id": "zvid-sac-0007",
      "name": "Pick and lock one checkout"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "zvid-sac-0008-condition",
              "leftValue": "={{ $json.found === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1830,
        480
      ],
      "id": "zvid-sac-0008",
      "name": "Checkout found?"
    },
    {
      "parameters": {
        "jsCode": "const result = $input.first().json; return [{ json: { recovered: false, rendered: false, reason: 'No eligible open abandoned checkout was found.', checked: result.checked || 0, skipped: result.skipped || [], morePages: Boolean(result.morePages) } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1570,
        480
      ],
      "id": "zvid-sac-0009",
      "name": "Nothing to recover"
    },
    {
      "parameters": {
        "amount": "={{ $json.isFixture ? $('Config').first().json.testWaitSeconds : Number($('Config').first().json.recoveryWaitMinutes || 60) * 60 }}",
        "unit": "seconds"
      },
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        2310,
        240
      ],
      "id": "zvid-sac-0010",
      "name": "Wait before recovery check"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "zvid-sac-0011-condition",
              "leftValue": "={{ $json.isFixture === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2570,
        240
      ],
      "id": "zvid-sac-0011",
      "name": "Use fixture confirmation?"
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json; return $input.all().map((item) => ({ json: { ...item.json, eligible: cfg.testFixtureEligible !== false, eligibilityReasons: cfg.testFixtureEligible === false ? ['fixture configured as ineligible'] : [] } }));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2830,
        240
      ],
      "id": "zvid-sac-0012",
      "name": "Approve checkout test fixture"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://' + $('Config').first().json.shopDomain + '.myshopify.com/admin/api/' + $('Config').first().json.shopifyApiVersion + '/graphql.json' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: \"query ConfirmAbandonedCheckout($id: ID!) {\\n  abandonmentByAbandonedCheckoutId(abandonedCheckoutId: $id) {\\n    id customerHasNoOrderSinceAbandonment customerHasNoDraftOrderSinceAbandonment\\n    inventoryAvailable emailSentAt emailState hoursSinceLastAbandonedCheckout\\n    isMostSignificantAbandonment\\n    abandonedCheckoutPayload { id completedAt abandonedCheckoutUrl }\\n  }\\n}\", variables: { id: $('Wait before recovery check').first().json.checkoutId } }) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          },
          "timeout": 60000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3090,
        240
      ],
      "id": "zvid-sac-0013",
      "name": "Confirm no purchase and inventory",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\nconst candidate = $('Wait before recovery check').first().json;\nconst response = $input.first().json || {};\nconst status = Number(response.statusCode || 0);\nconst body = response.body === undefined ? response : response.body;\nif (!status) throw new Error('Shopify returned no confirmation response.');\nif (status !== 200) throw new Error('Shopify confirmation failed (HTTP ' + status + '): ' + JSON.stringify(body).slice(0, 700));\nif (Array.isArray(body.errors) && body.errors.length) throw new Error('Shopify GraphQL error: ' + body.errors.map((error) => error.message).join('; '));\nconst abandonment = body.data && body.data.abandonmentByAbandonedCheckoutId;\nconst reasons = [];\nif (!abandonment) reasons.push('abandonment no longer exists or is not accessible');\nelse {\n  if (!abandonment.customerHasNoOrderSinceAbandonment) reasons.push('customer completed an order after abandoning');\n  if (!abandonment.customerHasNoDraftOrderSinceAbandonment) reasons.push('customer has a draft order after abandoning');\n  if (!abandonment.inventoryAvailable) reasons.push('one or more checkout items are unavailable');\n  if (!abandonment.isMostSignificantAbandonment) reasons.push('a newer customer abandonment superseded this checkout');\n  if (abandonment.abandonedCheckoutPayload && abandonment.abandonedCheckoutPayload.completedAt) reasons.push('checkout is completed');\n  if (cfg.skipIfShopifyAlreadyEmailed === true && abandonment.emailSentAt) reasons.push('Shopify already sent an abandonment email');\n}\nconst eligible = reasons.length === 0;\nif (!eligible) {\n  const store = $getWorkflowStaticData('global');\n  if (store.inFlightCheckouts) delete store.inFlightCheckouts[String(candidate.checkoutId)];\n}\nreturn [{ json: { ...candidate, eligible, eligibilityReasons: reasons, confirmation: abandonment ? { inventoryAvailable: abandonment.inventoryAvailable, emailState: abandonment.emailState, emailSentAt: abandonment.emailSentAt, hoursSinceLastAbandonedCheckout: abandonment.hoursSinceLastAbandonedCheckout } : null } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3090,
        480
      ],
      "id": "zvid-sac-0014",
      "name": "Check conversion and inventory"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "zvid-sac-0015-condition",
              "leftValue": "={{ $json.eligible === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2830,
        480
      ],
      "id": "zvid-sac-0015",
      "name": "Still eligible after the wait?"
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first().json; return [{ json: { rendered: false, checkoutId: item.checkoutId, checkoutName: item.checkoutName, stoppedSafely: true, reasons: item.eligibilityReasons || ['checkout is no longer eligible'] } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2570,
        480
      ],
      "id": "zvid-sac-0016",
      "name": "Stop recovered or unavailable checkout"
    },
    {
      "parameters": {
        "jsCode": "// === buildProject — identical to the frame-reviewed standalone builder ===\n// Abandoned checkout -> a personal 1080x1920 cart-recovery reel.\n// Three scenes: the personal hook (their first item), the cart line-up\n// (1-3 item cards, layout adapts to the count) and the total + CTA card.\nfunction buildProject(config, data) {\n  const cfg = config || {};\n  const clean = (v) => String(v == null ? '' : v).replace(/\\s+/g, ' ').trim();\n  const esc = (s) =>\n    String(s).replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[c]));\n  const round = (n) => Math.round(n * 100) / 100;\n\n  // ---- brand knobs --------------------------------------------------------\n  const brandName = clean(cfg.brandName) || 'Your store';\n  const storeUrl = clean(cfg.storeUrl) || 'yourstore.example';\n  const bg = clean(cfg.brandBackground) || '#12100E';\n  const accent = clean(cfg.brandAccent) || '#E2714B';\n  const accentSoft = clean(cfg.accentSoft) || '#F0B08C';\n  const cream = clean(cfg.creamColor) || '#F6F1E9';\n  const muted = clean(cfg.mutedColor) || '#9C948A';\n  const headingFont = clean(cfg.headingFont) || 'Sora';\n  const accentFont = clean(cfg.accentFont) || 'Fraunces';\n  const uiFont = clean(cfg.uiFont) || 'DM Sans';\n  const currency = clean(cfg.currencySymbol) || '$';\n\n  const hookLine = clean(cfg.hookLine) || 'you left something behind.';\n  const hookNote = clean(cfg.hookNote) || 'Your cart is still saved — for now.';\n  const cartKicker = clean(cfg.cartKicker) || 'STILL IN YOUR CART';\n  const cartNote = clean(cfg.cartNote) || 'We saved your cart. Nothing stays reserved forever.';\n  const totalLabel = clean(cfg.totalLabel) || 'YOUR CART TOTAL';\n  const ctaText = clean(cfg.ctaText) || 'Complete your order';\n  const ctaSubline = clean(cfg.ctaSubline) || 'Pick up exactly where you left off.';\n\n  // ---- cart data ----------------------------------------------------------\n  const firstNameRaw = clean(data.firstName);\n  // Only ever a first name goes near the video; the email address never does\n  // (it would end up in the public CDN filename).\n  const firstName = firstNameRaw ? firstNameRaw.slice(0, 24) : '';\n  const rawItems = Array.isArray(data.items) ? data.items : [];\n  const items = rawItems\n    .map((it) => ({\n      title: clean(it && it.title),\n      imageUrl: clean(it && it.imageUrl),\n      priceRaw: clean(it && it.price),\n    }))\n    .filter((it) => it.title)\n    .slice(0, 3);\n  if (!items.length) throw new Error('This cart has no line items with a title — nothing to render.');\n\n  // Only public http(s) images make it into the design; anything else falls\n  // back to a designed numbered tile instead of a broken picture box.\n  for (const it of items) {\n    if (!/^https?:\\/\\//i.test(it.imageUrl)) it.imageUrl = '';\n  }\n  const heroImage = (items.find((it) => it.imageUrl) || {}).imageUrl || '';\n\n  // ---- money --------------------------------------------------------------\n  // Prices arrive as free text (\"$49\", \"1,049.00\", \"49,00\"). Parse what we can;\n  // when even one item is unparseable the total line is replaced rather than\n  // shown wrong.\n  const parseMoney = (s) => {\n    const t = String(s || '').replace(/[^\\d.,-]/g, '').trim();\n    if (!t) return null;\n    let n;\n    if (/^-?\\d{1,3}(,\\d{3})+(\\.\\d+)?$/.test(t)) n = Number(t.replace(/,/g, ''));\n    else if (/^-?\\d{1,3}(\\.\\d{3})+(,\\d+)?$/.test(t)) n = Number(t.replace(/\\./g, '').replace(',', '.'));\n    else n = Number(t.replace(',', '.'));\n    return Number.isFinite(n) ? n : null;\n  };\n  const groupNum = (n, withCents) => {\n    const fixed = withCents || Math.abs(n % 1) > 0.0001 ? n.toFixed(2) : String(Math.round(n));\n    const [intPart, dec] = fixed.split('.');\n    return intPart.replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',') + (dec ? '.' + dec : '');\n  };\n  const hasSymbol = (s) => /[^\\d\\s.,-]/.test(String(s || ''));\n  // Cents are shown on the total only when the store itself quotes cents —\n  // \"$128.00 + $42.00 = $170\" reads wrong, \"= $170.00\" reads right.\n  const showsCents = items.some((it) => /[.,]\\d{2}(\\D|$)/.test(it.priceRaw));\n  const displayPrice = (raw) => {\n    if (!raw || !/\\d/.test(raw)) return '';\n    if (hasSymbol(raw)) return raw;\n    const n = parseMoney(raw);\n    return n === null ? raw : currency + groupNum(n, showsCents);\n  };\n\n  const parsedPrices = items.map((it) => parseMoney(it.priceRaw));\n  const suppliedTotal = parseMoney(data.totalPrice);\n  const allPriced = items.length > 0 && parsedPrices.every((p) => p !== null);\n  const totalNumber = suppliedTotal !== null ? suppliedTotal : allPriced ? parsedPrices.reduce((a, b) => a + b, 0) : null;\n  const totalDisplay = totalNumber === null ? '' : currency + groupNum(totalNumber, showsCents);\n\n  // ---- type metrics -------------------------------------------------------\n  // Greedy word-wrap estimate. AVG_EM was measured on the production\n  // renderer (Sora 700 at 33 px sets 18.6 px per character = 0.565 em) and\n  // rounded up, so the estimate errs towards shrinking type, never towards\n  // overflow.\n  const AVG_EM = 0.575;\n  const wrapLines = (text, fontPx, boxPx, avgEm) => {\n    const perLine = Math.max(6, Math.floor(boxPx / (fontPx * avgEm)));\n    let lines = 1;\n    let used = 0;\n    for (const w of String(text).split(' ')) {\n      const need = used === 0 ? w.length : used + 1 + w.length;\n      if (need <= perLine) { used = need; continue; }\n      lines += 1;\n      used = Math.min(w.length, perLine);\n    }\n    return lines;\n  };\n  // Shrinks a product title until it wraps into at most `maxLines`, then\n  // ellipsises what is still too long. Nothing ever spills out of a card.\n  const fitTitle = (text, sizes, boxPx, maxLines) => {\n    for (const size of sizes) {\n      if (wrapLines(text, size, boxPx, AVG_EM) <= maxLines) return { text, size };\n    }\n    const smallest = sizes[sizes.length - 1];\n    const perLine = Math.max(6, Math.floor(boxPx / (smallest * AVG_EM)));\n    const budget = Math.max(12, perLine * maxLines - 1);\n    let cut = String(text).slice(0, budget);\n    const lastSpace = cut.lastIndexOf(' ');\n    if (lastSpace > budget * 0.6) cut = cut.slice(0, lastSpace);\n    return { text: cut.replace(/[\\s,.;:-]+$/, '') + '…', size: smallest };\n  };\n\n  // ---- shared markup ------------------------------------------------------\n  // A thumbnail tile: the product photo when we have one, a numbered accent\n  // tile when we do not, so a missing image still looks designed.\n  const thumbTile = (item, index, size, radius) =>\n    item.imageUrl\n      ? \"<div style='width:\" + size + 'px; height:' + size + 'px; flex:none; border-radius:' + radius +\n        \"px; overflow:hidden; background:rgba(246,241,233,0.10);'><img src='\" + esc(item.imageUrl) +\n        \"' width='\" + size + \"' height='\" + size + \"' alt='' style='width:\" + size + 'px; height:' + size +\n        \"px; object-fit:cover; display:block;'/></div>\"\n      : \"<div style='width:\" + size + 'px; height:' + size + 'px; flex:none; border-radius:' + radius +\n        'px; background:rgba(226,113,75,0.16); color:' + accent + '; font-size:' + Math.round(size * 0.3) +\n        'px; font-weight:800; line-height:' + size + \"px; text-align:center;'>\" + (index + 1) + '</div>';\n\n  const svgOpen = \"<svg width='1080' height='1920' viewBox='0 0 1080 1920' xmlns='http://www.w3.org/2000/svg'>\";\n\n  // ==== scene 1 — the personal hook ========================================\n  const s1Seconds = 4.6;\n  const transitionSeconds = 0.5;\n\n  const nameSize = firstName.length <= 10 ? 92 : firstName.length <= 16 ? 76 : 62;\n  const nameY = 1168;\n  const hookSize = hookLine.length <= 30 ? 76 : hookLine.length <= 46 ? 66 : 56;\n  const hookY = nameY + Math.round(nameSize * 1.14) + 6;\n  const hookLines = wrapLines(hookLine, hookSize, 900, AVG_EM);\n  const noteY = hookY + Math.round(hookLines * hookSize * 1.08) + 44;\n\n  const s1 = {\n    id: 'hook',\n    duration: s1Seconds,\n    backgroundColor: bg,\n    transition: 'smoothup',\n    transitionId: 'cart',\n    transitionDuration: transitionSeconds,\n    visuals: [],\n  };\n\n  // The customer's own product photo, blurred and dimmed, is the backdrop:\n  // no stock footage to go stale, and the palette always matches the product.\n  if (heroImage) {\n    s1.visuals.push({\n      type: 'IMAGE', src: heroImage, width: 1080, height: 1920, position: 'center-center', resize: 'cover',\n      zoom: { depth: 1.14 }, filter: { blur: '5', brightness: -26, saturate: -14 },\n      track: 0, enterBegin: 0, exitEnd: s1Seconds,\n    });\n  }\n  s1.visuals.push({\n    type: 'SVG', width: 1080, height: 1920, track: 1, enterBegin: 0, exitEnd: s1Seconds,\n    svg: svgOpen +\n      \"<defs><linearGradient id='acvHookShade' x1='0' y1='0' x2='0' y2='1'>\" +\n      \"<stop offset='0' stop-color='\" + bg + \"' stop-opacity='0.78'/>\" +\n      \"<stop offset='0.32' stop-color='\" + bg + \"' stop-opacity='0.5'/>\" +\n      \"<stop offset='0.58' stop-color='\" + bg + \"' stop-opacity='0.72'/>\" +\n      \"<stop offset='1' stop-color='\" + bg + \"' stop-opacity='0.96'/></linearGradient>\" +\n      \"<radialGradient id='acvHookGlow' cx='0.5' cy='0.36' r='0.6'>\" +\n      \"<stop offset='0' stop-color='\" + accent + \"' stop-opacity='0.28'/>\" +\n      \"<stop offset='1' stop-color='\" + accent + \"' stop-opacity='0'/></radialGradient></defs>\" +\n      \"<rect width='1080' height='1920' fill='url(#acvHookShade)'/>\" +\n      \"<rect width='1080' height='1920' fill='url(#acvHookGlow)'/>\" +\n      \"<rect x='48' y='48' width='984' height='1824' rx='40' fill='none' stroke='rgba(246,241,233,0.18)' stroke-width='1.5'/>\" +\n      // With no product photo the frame would read flat, so the card gets a\n      // pair of halo rings instead of the blurred backdrop.\n      (heroImage\n        ? ''\n        : \"<circle cx='540' cy='680' r='404' fill='none' stroke='\" + accent + \"' stroke-opacity='0.32' stroke-width='2'/>\" +\n          \"<circle cx='540' cy='680' r='446' fill='none' stroke='\" + accent + \"' stroke-opacity='0.14' stroke-width='1'/>\") +\n      // corner ticks framing the product card (card box 230,300 -> 850,1060)\n      \"<path d='M206 348 L206 276 L278 276' fill='none' stroke='\" + accent + \"' stroke-width='4' stroke-opacity='0.85'/>\" +\n      \"<path d='M874 348 L874 276 L802 276' fill='none' stroke='\" + accent + \"' stroke-width='4' stroke-opacity='0.85'/>\" +\n      \"<path d='M206 1012 L206 1084 L278 1084' fill='none' stroke='\" + accent + \"' stroke-width='4' stroke-opacity='0.85'/>\" +\n      \"<path d='M874 1012 L874 1084 L802 1084' fill='none' stroke='\" + accent + \"' stroke-width='4' stroke-opacity='0.85'/>\" +\n      \"<path d='M90 1120 L200 1120' stroke='rgba(246,241,233,0.28)' stroke-width='1.5'/>\" +\n      '</svg>',\n  });\n  s1.visuals.push({\n    type: 'TEXT', x: 540, y: 128, anchor: 'top-center', track: 2,\n    enterBegin: 0.2, enterEnd: 0.7, enterAnimation: 'fade', exitEnd: s1Seconds,\n    style: { fontFamily: uiFont, color: cream },\n    html: \"<div style='display:inline-block; border:1.5px solid rgba(246,241,233,0.5); border-radius:999px; padding:15px 36px; color:\" +\n      cream + \"; font-size:25px; font-weight:700; letter-spacing:6px;'>\" + esc(brandName.toUpperCase()) + '</div>',\n  });\n  if (heroImage) {\n    s1.visuals.push({\n      type: 'IMAGE', src: heroImage, x: 540, y: 680, width: 620, height: 760, anchor: 'center-center',\n      radius: { tl: 40, tr: 40, bl: 40, br: 40 }, zoom: { depth: 1.07 },\n      track: 3, enterBegin: 0.1, enterEnd: 0.75, enterAnimation: 'smoothup', exitEnd: s1Seconds,\n    });\n  } else {\n    const placeholder = fitTitle(items[0].title, [58, 50, 44], 520, 3);\n    // Same box the hero photo would occupy (620x760 centred on 540,680), but\n    // declared top-left with no `height`: every TEXT element in this design\n    // sizes itself from its own html so the layout lint never mistakes a card\n    // for a collision with the full-frame backdrop.\n    s1.visuals.push({\n      type: 'TEXT', x: 230, y: 300, width: 620, anchor: 'top-left', track: 3,\n      enterBegin: 0.1, enterEnd: 0.75, enterAnimation: 'smoothup', exitEnd: s1Seconds,\n      style: { fontFamily: headingFont, color: cream },\n      html: \"<div style='width:620px; height:760px; border-radius:40px; overflow:hidden; display:flex; align-items:center; justify-content:center; background:rgba(246,241,233,0.07); box-shadow:inset 0 0 0 1.5px rgba(246,241,233,0.16);'>\" +\n        \"<div style='width:520px; color:\" + cream + '; font-size:' + placeholder.size +\n        \"px; line-height:1.12; font-weight:700; text-align:center;'>\" + esc(placeholder.text) + '</div></div>',\n    });\n  }\n  if (firstName) {\n    s1.visuals.push({\n      type: 'TEXT', x: 90, y: nameY, width: 900, anchor: 'top-left', track: 4,\n      enterBegin: 0.55, enterEnd: 1.05, enterAnimation: 'smoothup', exitEnd: s1Seconds,\n      style: { fontFamily: accentFont, color: accentSoft },\n      html: \"<div style='color:\" + accentSoft + '; font-size:' + nameSize +\n        \"px; line-height:1.06; font-style:italic; font-weight:500;'>\" + esc(firstName) + ',</div>',\n    });\n  }\n  s1.visuals.push({\n    type: 'TEXT', x: 90, y: firstName ? hookY : nameY, width: 900, anchor: 'top-left', track: 5,\n    enterBegin: 0.8, enterEnd: 1.3, enterAnimation: 'smoothup', exitEnd: s1Seconds,\n    style: { fontFamily: headingFont, color: cream },\n    html: \"<div style='color:\" + cream + '; font-size:' + hookSize +\n      \"px; line-height:1.08; font-weight:800; letter-spacing:-1px;'>\" + esc(hookLine) + '</div>',\n  });\n  s1.visuals.push({\n    type: 'TEXT', x: 90, y: firstName ? noteY : noteY - Math.round(nameSize * 1.14) - 6, width: 860,\n    anchor: 'top-left', track: 6,\n    enterBegin: 1.35, enterEnd: 1.8, enterAnimation: 'fade', exitEnd: s1Seconds,\n    style: { fontFamily: uiFont, color: muted },\n    html: \"<div style='color:\" + muted + \"; font-size:30px; line-height:1.4; font-weight:500;'>\" + esc(hookNote) + '</div>',\n  });\n\n  // ==== scene 2 — the cart line-up ========================================\n  const n = items.length;\n  const s2Seconds = n === 1 ? 5.0 : n === 2 ? 5.8 : 6.4;\n  const s2 = {\n    id: 'cart',\n    duration: s2Seconds,\n    backgroundColor: bg,\n    transition: 'fade',\n    transitionId: 'cta',\n    transitionDuration: transitionSeconds,\n    visuals: [],\n  };\n  if (heroImage) {\n    s2.visuals.push({\n      type: 'IMAGE', src: heroImage, width: 1080, height: 1920, position: 'center-center', resize: 'cover',\n      zoom: { depth: 1.1 }, filter: { blur: '7', brightness: -46, saturate: -26 },\n      track: 0, enterBegin: 0, exitEnd: s2Seconds,\n    });\n  }\n  s2.visuals.push({\n    type: 'SVG', width: 1080, height: 1920, track: 1, enterBegin: 0, exitEnd: s2Seconds,\n    svg: svgOpen +\n      \"<defs><linearGradient id='acvCartShade' x1='0' y1='0' x2='0' y2='1'>\" +\n      \"<stop offset='0' stop-color='\" + bg + \"' stop-opacity='0.9'/>\" +\n      \"<stop offset='0.5' stop-color='\" + bg + \"' stop-opacity='0.82'/>\" +\n      \"<stop offset='1' stop-color='\" + bg + \"' stop-opacity='0.95'/></linearGradient></defs>\" +\n      \"<rect width='1080' height='1920' fill='url(#acvCartShade)'/>\" +\n      \"<rect x='48' y='48' width='984' height='1824' rx='40' fill='none' stroke='rgba(246,241,233,0.16)' stroke-width='1.5'/>\" +\n      \"<rect x='90' y='330' width='900' height='2' fill='rgba(246,241,233,0.16)'/>\" +\n      \"<rect x='90' y='326' width='104' height='6' rx='3' fill='\" + accent + \"'/>\" +\n      '</svg>',\n  });\n  s2.visuals.push({\n    type: 'TEXT', x: 90, y: 150, anchor: 'top-left', track: 2,\n    enterBegin: 0.3, enterEnd: 0.7, enterAnimation: 'fade', exitEnd: s2Seconds,\n    style: { fontFamily: uiFont, color: accentSoft },\n    html: \"<div style='color:\" + accentSoft + \"; font-size:25px; font-weight:700; letter-spacing:6px;'>\" +\n      esc(cartKicker.toUpperCase()) + '</div>',\n  });\n  s2.visuals.push({\n    type: 'TEXT', x: 90, y: 198, width: 900, anchor: 'top-left', track: 3,\n    enterBegin: 0.4, enterEnd: 0.85, enterAnimation: 'smoothup', exitEnd: s2Seconds,\n    style: { fontFamily: headingFont, color: cream },\n    html: \"<div style='color:\" + cream + \"; font-size:66px; line-height:1.04; font-weight:800; letter-spacing:-1px;'>\" +\n      n + (n === 1 ? ' item' : ' items') + \" waiting</div>\",\n  });\n\n  const cardShell = (w, h, radius) =>\n    'width:' + w + 'px; height:' + h + 'px; border-radius:' + radius +\n    'px; overflow:hidden; background:rgba(246,241,233,0.075); box-shadow:inset 0 0 0 1.5px rgba(246,241,233,0.15);';\n\n  // The card block always starts just under the rule and lands near y=1460,\n  // so 1, 2 and 3 items each fill the frame instead of floating in it.\n  let rowsBottom;\n  if (n === 1) {\n    const item = items[0];\n    const title = fitTitle(item.title, [46, 42, 38], 828, 2);\n    const price = displayPrice(item.priceRaw);\n    const cardY = 420;\n    const media = item.imageUrl\n      ? \"<div style='width:836px; height:780px; margin:32px 0 0 32px; border-radius:24px; overflow:hidden; background:rgba(246,241,233,0.10);'><img src='\" +\n        esc(item.imageUrl) + \"' width='836' height='780' alt='' style='width:836px; height:780px; object-fit:cover; display:block;'/></div>\"\n      : \"<div style='width:836px; height:780px; margin:32px 0 0 32px; border-radius:24px; background:rgba(226,113,75,0.14); box-shadow:inset 0 0 0 1.5px rgba(226,113,75,0.35);'></div>\";\n    s2.visuals.push({\n      type: 'TEXT', x: 90, y: cardY, width: 900, anchor: 'top-left', track: 4,\n      enterBegin: 0.75, enterEnd: 1.3, enterAnimation: 'smoothup', exitEnd: s2Seconds,\n      style: { fontFamily: headingFont, color: cream },\n      html: \"<div style='\" + cardShell(900, 1090, 34) + \"'>\" + media +\n        \"<div style='margin:30px 0 0 34px; width:828px;'>\" +\n        \"<div style='color:\" + cream + '; font-size:' + title.size + \"px; line-height:1.14; font-weight:700;'>\" + esc(title.text) + '</div>' +\n        (price ? \"<div style='margin-top:16px; color:\" + accent + \"; font-size:44px; font-weight:800;'>\" + esc(price) + '</div>' : '') +\n        '</div></div>',\n    });\n    rowsBottom = cardY + 1090;\n  } else {\n    const two = n === 2;\n    const cardH = two ? 500 : 330;\n    const gap = two ? 64 : 40;\n    const thumb = two ? 380 : 250;\n    const textW = 808 - thumb;\n    const titleSizes = two ? [38, 34, 30] : [36, 33, 30];\n    const maxLines = 3;\n    const priceSize = two ? 38 : 34;\n    const startY = two ? 460 : 430;\n    items.forEach((item, i) => {\n      const title = fitTitle(item.title, titleSizes, textW, maxLines);\n      const price = displayPrice(item.priceRaw);\n      const y = startY + i * (cardH + gap);\n      s2.visuals.push({\n        type: 'TEXT', x: 90, y: y, width: 900, anchor: 'top-left', track: 4 + i,\n        enterBegin: round(0.7 + i * 0.28), enterEnd: round(1.2 + i * 0.28), enterAnimation: 'smoothleft', exitEnd: s2Seconds,\n        style: { fontFamily: headingFont, color: cream },\n        html: \"<div style='\" + cardShell(900, cardH, 28) + \" display:flex; align-items:center;'>\" +\n          \"<div style='width:5px; height:\" + thumb + \"px; flex:none; border-radius:0 4px 4px 0; background:\" + accent + \";'></div>\" +\n          \"<div style='width:25px; flex:none;'></div>\" +\n          thumbTile(item, i, thumb, 20) +\n          \"<div style='width:28px; flex:none;'></div>\" +\n          \"<div style='width:\" + textW + \"px; flex:none;'>\" +\n          \"<div style='color:\" + cream + '; font-size:' + title.size + \"px; line-height:1.16; font-weight:700;'>\" + esc(title.text) + '</div>' +\n          (price ? \"<div style='margin-top:12px; color:\" + accent + '; font-size:' + priceSize + \"px; font-weight:800;'>\" + esc(price) + '</div>' : '') +\n          '</div></div>',\n      });\n    });\n    rowsBottom = startY + n * cardH + (n - 1) * gap;\n  }\n  const cartNoteY = Math.min(1700, Math.max(1580, rowsBottom + 130));\n  s2.visuals.push({\n    type: 'TEXT', x: 540, y: cartNoteY, width: 880, anchor: 'top-center', track: 9,\n    enterBegin: round(0.9 + n * 0.28), enterEnd: round(1.35 + n * 0.28), enterAnimation: 'fade', exitEnd: s2Seconds,\n    style: { fontFamily: uiFont, color: muted },\n    html: \"<div style='text-align:center; color:\" + muted + \"; font-size:29px; line-height:1.42; font-weight:500;'>\" +\n      esc(cartNote) + '</div>',\n  });\n\n  // ==== scene 3 — total + CTA =============================================\n  const s3Seconds = 5.2;\n  const totalSize = totalDisplay.length <= 7 ? 152 : totalDisplay.length <= 9 ? 132 : 112;\n  const s3 = {\n    id: 'cta',\n    duration: s3Seconds,\n    backgroundColor: bg,\n    visuals: [\n      {\n        type: 'SVG', width: 1080, height: 1920, track: 0, enterBegin: 0, exitEnd: s3Seconds,\n        svg: svgOpen +\n          \"<defs><radialGradient id='acvCtaGlow' cx='0.5' cy='0.42' r='0.85'>\" +\n          \"<stop offset='0' stop-color='\" + accent + \"' stop-opacity='0.3'/>\" +\n          \"<stop offset='0.55' stop-color='\" + accent + \"' stop-opacity='0.06'/>\" +\n          \"<stop offset='1' stop-color='\" + accent + \"' stop-opacity='0'/></radialGradient></defs>\" +\n          \"<rect width='1080' height='1920' fill='\" + bg + \"'/>\" +\n          \"<rect width='1080' height='1920' fill='url(#acvCtaGlow)'/>\" +\n          \"<rect x='48' y='48' width='984' height='1824' rx='40' fill='none' stroke='rgba(246,241,233,0.2)' stroke-width='1.5'/>\" +\n          \"<circle cx='540' cy='700' r='300' fill='none' stroke='\" + accent + \"' stroke-opacity='0.3' stroke-width='2'/>\" +\n          \"<circle cx='540' cy='700' r='332' fill='none' stroke='\" + accent + \"' stroke-opacity='0.12' stroke-width='1'/>\" +\n          \"<path d='M120 1616 L120 1800 L304 1800' fill='none' stroke='\" + accent + \"' stroke-width='6' stroke-opacity='0.8'/>\" +\n          \"<path d='M960 304 L960 120 L776 120' fill='none' stroke='\" + accent + \"' stroke-width='6' stroke-opacity='0.8'/>\" +\n          '</svg>',\n      },\n      {\n        type: 'TEXT', x: 540, y: 128, anchor: 'top-center', track: 1,\n        enterBegin: 0.15, enterEnd: 0.6, enterAnimation: 'fade', exitEnd: s3Seconds,\n        style: { fontFamily: uiFont, color: cream },\n        html: \"<div style='display:inline-block; border:1.5px solid rgba(246,241,233,0.5); border-radius:999px; padding:15px 36px; color:\" +\n          cream + \"; font-size:25px; font-weight:700; letter-spacing:6px;'>\" + esc(brandName.toUpperCase()) + '</div>',\n      },\n    ],\n  };\n  // The total is optically centred inside the ring: the number grows upward\n  // from a fixed baseline so the \"3 items · saved for …\" line never moves.\n  const itemsLineY = 800;\n  if (totalDisplay) {\n    const totalY = itemsLineY - 34 - Math.round(totalSize * 1.02);\n    s3.visuals.push({\n      type: 'TEXT', x: 540, y: totalY - 60, anchor: 'top-center', track: 2,\n      enterBegin: 0.4, enterEnd: 0.85, enterAnimation: 'fade', exitEnd: s3Seconds,\n      style: { fontFamily: uiFont, color: muted },\n      html: \"<div style='text-align:center; color:\" + muted + \"; font-size:25px; font-weight:700; letter-spacing:6px;'>\" +\n        esc(totalLabel.toUpperCase()) + '</div>',\n    });\n    s3.visuals.push({\n      type: 'TEXT', x: 540, y: totalY, width: 940, anchor: 'top-center', track: 3,\n      enterBegin: 0.55, enterEnd: 1.0, enterAnimation: 'smoothup', exitEnd: s3Seconds,\n      style: { fontFamily: headingFont, color: cream },\n      html: \"<div style='text-align:center; color:\" + cream + '; font-size:' + totalSize +\n        \"px; line-height:1.02; font-weight:800; letter-spacing:-3px;'>\" + esc(totalDisplay) + '</div>',\n    });\n  } else {\n    s3.visuals.push({\n      type: 'TEXT', x: 540, y: 590, width: 900, anchor: 'top-center', track: 3,\n      enterBegin: 0.5, enterEnd: 0.95, enterAnimation: 'smoothup', exitEnd: s3Seconds,\n      style: { fontFamily: headingFont, color: cream },\n      html: \"<div style='text-align:center; color:\" + cream +\n        \"; font-size:82px; line-height:1.14; font-weight:800; letter-spacing:-2px;'>Your cart<br/>is waiting.</div>\",\n    });\n  }\n  s3.visuals.push({\n    type: 'TEXT', x: 540, y: itemsLineY, width: 900, anchor: 'top-center', track: 4,\n    enterBegin: 0.9, enterEnd: 1.3, enterAnimation: 'fade', exitEnd: s3Seconds,\n    style: { fontFamily: uiFont, color: accentSoft },\n    html: \"<div style='text-align:center; color:\" + accentSoft + \"; font-size:30px; font-weight:600; letter-spacing:1px;'>\" +\n      n + (n === 1 ? ' item' : ' items') + ' · saved for ' + esc(firstName || 'you') + '</div>',\n  });\n  s3.visuals.push({\n    type: 'TEXT', x: 540, y: 1010, anchor: 'top-center', track: 5,\n    enterBegin: 1.15, enterEnd: 1.65, enterAnimation: 'circleopen', exitEnd: s3Seconds,\n    // The pill paints ink-on-accent (the html below wins), but `style.color` is\n    // what the contrast lint reads and it can only see the scene background —\n    // so declare the accent, which is the ratio the pill really achieves.\n    style: { fontFamily: headingFont, color: accent },\n    html: \"<div style='display:inline-block; background:\" + accent + '; color:' + bg +\n      \"; border-radius:999px; padding:30px 66px; font-size:42px; font-weight:800;'>\" + esc(ctaText) + ' →</div>',\n  });\n  s3.visuals.push({\n    type: 'TEXT', x: 540, y: 1186, width: 840, anchor: 'top-center', track: 6,\n    enterBegin: 1.5, enterEnd: 1.9, enterAnimation: 'fade', exitEnd: s3Seconds,\n    style: { fontFamily: uiFont, color: muted },\n    html: \"<div style='text-align:center; color:\" + muted + \"; font-size:29px; line-height:1.42; font-weight:500;'>\" +\n      esc(ctaSubline) + '</div>',\n  });\n  s3.visuals.push({\n    type: 'TEXT', x: 540, y: 1318, width: 900, anchor: 'top-center', track: 7,\n    enterBegin: 1.7, enterEnd: 2.15, enterAnimation: 'smoothup', exitEnd: s3Seconds,\n    style: { fontFamily: headingFont, color: cream },\n    html: \"<div style='width:900px; height:140px; display:flex; align-items:center; justify-content:center;'>\" +\n      items.map((item, i) => \"<div style='width:\" + (i ? 24 : 0) + \"px; flex:none;'></div>\" + thumbTile(item, i, 124, 20)).join('') +\n      '</div>',\n  });\n  s3.visuals.push({\n    type: 'TEXT', x: 540, y: 1520, anchor: 'top-center', track: 8,\n    enterBegin: 1.95, enterEnd: 2.3, enterAnimation: 'fade', exitEnd: s3Seconds,\n    style: { fontFamily: uiFont, color: muted },\n    html: \"<div style='text-align:center; color:\" + muted + \"; font-size:26px; font-weight:600; letter-spacing:4px;'>\" +\n      esc(storeUrl.replace(/^https?:\\/\\//i, '').replace(/\\/+$/, '').toUpperCase()) + '</div>',\n  });\n\n  // ---- payload ------------------------------------------------------------\n  const totalSeconds = round(s1Seconds + s2Seconds + s3Seconds - 2 * transitionSeconds);\n\n  // The finished video sits on a public CDN, so the filename carries a first\n  // name and a hash of the cart contents — never the email address.\n  let hash = 0;\n  const hashSeed = items.map((it) => it.title).join('|') + '|' + (data.cartRef || '') + '|' + (totalDisplay || '');\n  for (let i = 0; i < hashSeed.length; i += 1) hash = (hash * 31 + hashSeed.charCodeAt(i)) % 2821109907456;\n  const slug = (s) => String(s).toLowerCase().normalize('NFKD').replace(/[\\u0300-\\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');\n  const safeName = ('cart-' + (slug(firstName) || 'customer') + '-' + hash.toString(36)).slice(0, 60);\n\n  const payload = {\n    name: safeName,\n    resolution: clean(cfg.resolution) || 'instagram-reel',\n    frameRate: Number(cfg.frameRate || 30),\n    backgroundColor: bg,\n    outputFormat: 'mp4',\n    scenes: [s1, s2, s3],\n  };\n  // NOTE: no `track` on audios — the render API rejects audios[].track.\n  if (data.music && data.music.src) {\n    payload.audios = [{ src: data.music.src, volume: Number(cfg.musicVolume ?? 0.16) }];\n  }\n\n  return {\n    payload,\n    meta: {\n      firstName: firstName || null,\n      itemCount: n,\n      itemTitles: items.map((it) => it.title),\n      itemsWithImages: items.filter((it) => it.imageUrl).length,\n      heroImage: heroImage || null,\n      totalDisplay: totalDisplay || null,\n      totalNumber: totalNumber,\n      layout: n === 1 ? 'single-hero-card' : n === 2 ? 'two-tall-rows' : 'three-rows',\n      s1Seconds, s2Seconds, s3Seconds, transitionSeconds, totalSeconds,\n      musicUsed: Boolean(data.music && data.music.src),\n      musicNote: data.musicNote || null,\n      checkoutUrl: data.checkoutUrl || null,\n      projectName: safeName,\n    },\n  };\n}\n\n// === n8n glue ===\nconst config = $('Config').first().json;\nconst cart = $input.first().json;\nconst musicUrl = /^https?:\\/\\//i.test(String(config.musicUrl || '')) ? String(config.musicUrl) : '';\nconst built = buildProject(config, {\n  firstName: cart.firstName,\n  items: cart.items,\n  totalPrice: cart.totalPrice,\n  checkoutUrl: cart.checkoutUrl,\n  cartRef: cart.cartRef,\n  music: musicUrl ? { src: musicUrl } : null,\n  musicNote: musicUrl ? 'configured' : 'no music configured',\n});\nObject.assign(built.meta, {\n  checkoutId: cart.checkoutId || null,\n  checkoutName: cart.checkoutName || null,\n  source: cart.source || 'shopify',\n  isFixture: Boolean(cart.isFixture),\n  email: cart.email || null,\n  phone: cart.phone || null,\n  detectedAt: cart.detectedAt || null,\n});\nreturn [{ json: built }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        50,
        1160
      ],
      "id": "zvid-sac-0017",
      "name": "Build personalized recovery video"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "validate",
        "source": "json",
        "projectJson": "={{ $json.payload }}",
        "validationVariables": "{}",
        "additionalFields": {}
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        310,
        1160
      ],
      "id": "zvid-sac-0018",
      "name": "Validate project (free)"
    },
    {
      "parameters": {
        "jsCode": "const result = $input.first().json || {};\nif (!result.valid) {\n  const details = Array.isArray(result.errors)\n    ? result.errors.map((entry) => '  - ' + (entry.field || 'project') + ': ' + entry.message).join('\\n')\n    : JSON.stringify(result).slice(0, 500);\n  throw new Error('Zvid rejected the project: ' + (result.message || result.error || 'Validation failed') + '\\n' + details);\n}\nconst build = $('Build personalized recovery video').first().json;\nreturn [{ json: {\n  ...build,\n  payload: result.payload || build.payload,\n  creditsRequired: Number(result.creditsRequired) || 0,\n  warnings: result.warnings || [],\n  schemaVersion: result.schemaVersion,\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        570,
        1160
      ],
      "id": "zvid-sac-0019",
      "name": "Check validation"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "dry-run--cond",
              "leftValue": "={{ $('Config').first().json.dryRun }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1050,
        1160
      ],
      "id": "zvid-sac-0020",
      "name": "Dry run?"
    },
    {
      "parameters": {
        "resource": "project",
        "operation": "create",
        "projectName": "={{ String($json.payload.name || ('Abandoned checkout ' + ($json.meta.checkoutName || $json.meta.checkoutId || 'preview'))).slice(0, 120) }}",
        "projectJson": "={{ $json.payload }}"
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        1310,
        1160
      ],
      "id": "zvid-sac-0021",
      "name": "Save draft to editor",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\nconst checked = $('Check validation').first().json;\nconst meta = checked.meta;\nif (!meta.isFixture && meta.checkoutId) {\n  const store = $getWorkflowStaticData('global');\n  if (store.inFlightCheckouts) delete store.inFlightCheckouts[String(meta.checkoutId)];\n}\nlet editorLink = null;\nlet editorDraftError = null;\ntry {\n  const saved = $('Save draft to editor').first().json;\n  if (saved.project && saved.project.id) editorLink = String(cfg.editorUrl).replace(/\\/+$/, '') + '/?project=' + saved.project.id;\n  if (saved.error) editorDraftError = String(saved.error.message || saved.error).slice(0, 500);\n} catch (error) { editorDraftError = String(error.message || error).slice(0, 500); }\nreturn [{ json: {\n  dryRun: true,\n  fixture: meta.isFixture,\n  checkoutId: meta.checkoutId,\n  checkoutName: meta.checkoutName,\n  itemCount: meta.itemCount,\n  items: meta.itemTitles,\n  cartTotal: meta.totalDisplay,\n  layout: meta.layout,\n  videoSeconds: meta.totalSeconds,\n  creditsRequired: checked.creditsRequired,\n  warnings: checked.warnings,\n  editorLink,\n  editorDraftError,\n  customerEmailExcludedFromVideo: true,\n  nextStep: 'Review the editor draft and credit quote. Set dryRun to false only when the rendered video and optional email are approved.',\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1570,
        1160
      ],
      "id": "zvid-sac-0022",
      "name": "Dry run summary"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "create",
        "renderType": "video",
        "source": "json",
        "projectJson": "={{ $json.payload }}",
        "waitForCompletion": false
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        2050,
        1160
      ],
      "id": "zvid-sac-0023",
      "name": "Submit render",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "amount": "={{ $('Config').first().json.pollSeconds }}",
        "unit": "seconds"
      },
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        2310,
        1160
      ],
      "id": "zvid-sac-0024",
      "name": "Wait for render"
    },
    {
      "parameters": {
        "resource": "render",
        "operation": "get",
        "jobId": "={{ $('Submit render').first().json.jobId }}",
        "waitForCompletion": false
      },
      "type": "@zvid/n8n-nodes-zvid.zvid",
      "typeVersion": 1,
      "position": [
        2570,
        1160
      ],
      "id": "zvid-sac-0025",
      "name": "Get render status",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "render-finished--cond",
              "leftValue": "={{ $json.state === 'completed' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2830,
        1160
      ],
      "id": "zvid-sac-0026",
      "name": "Render finished?"
    },
    {
      "parameters": {
        "jsCode": "// Fails fast on a failed render and stops the poll loop from running forever.\nconst cfg = $('Config').first().json;\nconst job = $input.first().json;\n\nif (job.state === 'failed') {\n  throw new Error('Render failed: ' + (job.failedReason || 'no reason given'));\n}\n\nconst maxPolls = Math.ceil((Number(cfg.timeoutMinutes || 20) * 60) / Number(cfg.pollSeconds || 10));\nif ($runIndex + 1 >= maxPolls) {\n  throw new Error('Render did not finish within ' + cfg.timeoutMinutes + ' minutes (job ' + job.id + ', state ' + job.state + ').');\n}\n\nreturn $input.all();\n"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2830,
        1400
      ],
      "id": "zvid-sac-0027",
      "name": "Still rendering?"
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\nconst build = $('Check validation').first().json;\nconst job = $input.first().json;\nconst meta = build.meta;\nconst videoUrl = typeof job.result === 'string' ? job.result : (job.result && job.result.url);\nif (!videoUrl) throw new Error('The completed render returned no video URL (job ' + String(job.id || 'unknown') + ').');\nconst esc = (value) => String(value == null ? '' : value).replace(/[&<>\"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[char]));\nconst firstName = String(meta.firstName || 'there');\nconst fill = (template) => String(template || '')\n  .replace(/\\{firstName\\}/g, firstName)\n  .replace(/\\{brandName\\}/g, String(cfg.brandName || ''))\n  .replace(/\\{total\\}/g, String(meta.totalDisplay || ''))\n  .replace(/\\{itemCount\\}/g, String(meta.itemCount || 0));\nconst safeCheckout = /^https?:\\/\\//i.test(String(meta.checkoutUrl || '')) ? String(meta.checkoutUrl) : '';\nconst bg = String(cfg.brandBackground || '#12100E');\nconst accent = String(cfg.brandAccent || '#E2714B');\nconst cream = String(cfg.creamColor || '#F6F1E9');\nconst muted = String(cfg.mutedColor || '#9C948A');\nconst rows = (meta.itemTitles || []).map((title) => '<li style=\"margin:6px 0\">' + esc(title) + '</li>').join('');\nconst makeHtml = (intro) => '<div style=\"font-family:Arial,sans-serif;background:' + bg + ';color:' + cream + ';padding:36px;border-radius:18px;max-width:560px\">' +\n  '<p style=\"letter-spacing:3px;color:' + accent + ';font-size:12px;text-transform:uppercase\">' + esc(cfg.brandName || 'Your store') + '</p>' +\n  '<h1 style=\"font-size:28px\">' + esc(firstName) + ', your cart is still saved.</h1>' +\n  '<p style=\"color:' + muted + ';line-height:1.6\">' + esc(fill(intro)) + '</p><ul>' + rows + '</ul>' +\n  (meta.totalDisplay ? '<p style=\"font-size:22px;font-weight:bold\">' + esc(meta.totalDisplay) + '</p>' : '') +\n  (safeCheckout ? '<p><a href=\"' + esc(safeCheckout) + '\" style=\"display:inline-block;background:' + accent + ';color:' + bg + ';padding:14px 28px;border-radius:999px;text-decoration:none;font-weight:bold\">' + esc(cfg.emailButtonText || 'Complete your order') + '</a></p>' : '') +\n  '<p><a href=\"' + esc(videoUrl) + '\" style=\"color:' + accent + '\">Watch your personalized cart video</a></p></div>';\nreturn [{ json: {\n  videoUrl,\n  jobId: job.id,\n  creditsCharged: build.creditsRequired,\n  checkoutId: meta.checkoutId,\n  checkoutName: meta.checkoutName,\n  isFixture: meta.isFixture,\n  email: meta.email,\n  phone: meta.phone,\n  firstName,\n  checkoutUrl: meta.checkoutUrl,\n  itemCount: meta.itemCount,\n  items: meta.itemTitles,\n  cartTotal: meta.totalDisplay,\n  emailSubject: fill(cfg.emailSubject || '{firstName}, your cart is still saved'),\n  emailHtml: makeHtml(cfg.emailIntro),\n  followUpSubject: fill(cfg.followUpSubject || '{firstName}, one last look at your saved cart'),\n  followUpHtml: makeHtml(cfg.followUpIntro),\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        50,
        2080
      ],
      "id": "zvid-sac-0028",
      "name": "Prepare private recovery delivery"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "zvid-sac-0029-condition",
              "leftValue": "={{ $('Config').first().json.sendEmail === true && String($json.email || '').includes('@') }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        310,
        2080
      ],
      "id": "zvid-sac-0029",
      "name": "Send initial recovery email?"
    },
    {
      "parameters": {
        "fromEmail": "={{ $('Config').first().json.emailFrom }}",
        "toEmail": "={{ $('Prepare private recovery delivery').first().json.email }}",
        "subject": "={{ $('Prepare private recovery delivery').first().json.emailSubject }}",
        "emailFormat": "html",
        "html": "={{ $('Prepare private recovery delivery').first().json.emailHtml }}",
        "options": {}
      },
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        570,
        2080
      ],
      "id": "zvid-sac-0030",
      "name": "Send initial recovery email",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\nconst delivery = $('Prepare private recovery delivery').first().json;\nlet initialEmailSent = false;\nlet initialEmailError = null;\nif (cfg.sendEmail === true && String(delivery.email || '').includes('@')) {\n  try {\n    const result = $('Send initial recovery email').first().json;\n    initialEmailSent = Boolean(result) && !result.error;\n    if (result && result.error) initialEmailError = String(result.error.message || result.error).slice(0, 500);\n  } catch (error) { initialEmailError = String(error.message || error).slice(0, 500); }\n}\nreturn [{ json: { ...delivery, initialEmailAttempted: cfg.sendEmail === true, initialEmailSent, initialEmailError } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        830,
        2080
      ],
      "id": "zvid-sac-0031",
      "name": "Record initial delivery result"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "zvid-sac-0032-condition",
              "leftValue": "={{ $('Config').first().json.followUpEnabled === true && (!$json.isFixture || $('Config').first().json.testFollowUpInFixture === true) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1310,
        2080
      ],
      "id": "zvid-sac-0032",
      "name": "Wait for a follow-up check?"
    },
    {
      "parameters": {
        "amount": "={{ $json.isFixture ? $('Config').first().json.testFollowUpSeconds : Number($('Config').first().json.followUpWaitMinutes || 720) * 60 }}",
        "unit": "seconds"
      },
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1570,
        2080
      ],
      "id": "zvid-sac-0033",
      "name": "Wait before follow-up"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "zvid-sac-0034-condition",
              "leftValue": "={{ $json.isFixture === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1830,
        2080
      ],
      "id": "zvid-sac-0034",
      "name": "Use fixture follow-up?"
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json; const delivery = $('Record initial delivery result').first().json; const recovered = cfg.testFixtureRecoveredBeforeFollowUp === true; return [{ json: { ...delivery, stillAbandoned: !recovered, followUpStopReason: recovered ? 'fixture simulates a completed order' : null } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2090,
        2080
      ],
      "id": "zvid-sac-0035",
      "name": "Simulate conversion before follow-up"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://' + $('Config').first().json.shopDomain + '.myshopify.com/admin/api/' + $('Config').first().json.shopifyApiVersion + '/graphql.json' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: \"query ConfirmAbandonedCheckout($id: ID!) {\\n  abandonmentByAbandonedCheckoutId(abandonedCheckoutId: $id) {\\n    id customerHasNoOrderSinceAbandonment customerHasNoDraftOrderSinceAbandonment\\n    inventoryAvailable emailSentAt emailState hoursSinceLastAbandonedCheckout\\n    isMostSignificantAbandonment\\n    abandonedCheckoutPayload { id completedAt abandonedCheckoutUrl }\\n  }\\n}\", variables: { id: $('Prepare private recovery delivery').first().json.checkoutId } }) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          },
          "timeout": 60000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2090,
        2320
      ],
      "id": "zvid-sac-0036",
      "name": "Confirm before follow-up",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "const delivery = $('Record initial delivery result').first().json;\nconst response = $input.first().json || {};\nconst status = Number(response.statusCode || 0);\nconst body = response.body === undefined ? response : response.body;\nif (!status) throw new Error('Shopify returned no follow-up confirmation response.');\nif (status !== 200) throw new Error('Shopify follow-up confirmation failed (HTTP ' + status + '): ' + JSON.stringify(body).slice(0, 700));\nif (Array.isArray(body.errors) && body.errors.length) throw new Error('Shopify GraphQL error: ' + body.errors.map((error) => error.message).join('; '));\nconst abandonment = body.data && body.data.abandonmentByAbandonedCheckoutId;\nconst stillAbandoned = Boolean(abandonment && abandonment.customerHasNoOrderSinceAbandonment && abandonment.customerHasNoDraftOrderSinceAbandonment && abandonment.inventoryAvailable && abandonment.isMostSignificantAbandonment && !(abandonment.abandonedCheckoutPayload && abandonment.abandonedCheckoutPayload.completedAt));\nreturn [{ json: { ...delivery, stillAbandoned, followUpStopReason: stillAbandoned ? null : 'conversion, draft order, newer abandonment, or unavailable inventory detected' } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1830,
        2320
      ],
      "id": "zvid-sac-0037",
      "name": "Check follow-up conversion"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "zvid-sac-0038-condition",
              "leftValue": "={{ $json.stillAbandoned === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2570,
        2080
      ],
      "id": "zvid-sac-0038",
      "name": "Still abandoned at follow-up?"
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first().json; return [{ json: { ...item, followUpAttempted: false, followUpEmailSent: false, followUpStoppedByConversion: true } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2830,
        2080
      ],
      "id": "zvid-sac-0039",
      "name": "Stop follow-up after conversion"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "zvid-sac-0040-condition",
              "leftValue": "={{ $('Config').first().json.sendEmail === true && String($json.email || '').includes('@') }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3090,
        2080
      ],
      "id": "zvid-sac-0040",
      "name": "Send follow-up email?"
    },
    {
      "parameters": {
        "fromEmail": "={{ $('Config').first().json.emailFrom }}",
        "toEmail": "={{ $('Prepare private recovery delivery').first().json.email }}",
        "subject": "={{ $('Prepare private recovery delivery').first().json.followUpSubject }}",
        "emailFormat": "html",
        "html": "={{ $('Prepare private recovery delivery').first().json.followUpHtml }}",
        "options": {}
      },
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        3350,
        2080
      ],
      "id": "zvid-sac-0041",
      "name": "Send follow-up recovery email",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "const delivery = $('Prepare private recovery delivery').first().json;\nconst initial = $('Record initial delivery result').first().json;\nconst store = $getWorkflowStaticData('global');\nif (!Array.isArray(store.handledCheckoutIds)) store.handledCheckoutIds = [];\nif (!store.inFlightCheckouts || typeof store.inFlightCheckouts !== 'object') store.inFlightCheckouts = {};\nconst id = String(delivery.checkoutId || '');\nif (id) delete store.inFlightCheckouts[id];\nif (!delivery.isFixture && id && !store.handledCheckoutIds.map(String).includes(id)) store.handledCheckoutIds.push(id);\nif (store.handledCheckoutIds.length > 500) store.handledCheckoutIds = store.handledCheckoutIds.slice(-500);\nlet followUpEmailSent = false;\nlet followUpEmailError = null;\ntry {\n  const response = $('Send follow-up recovery email').first().json;\n  followUpEmailSent = Boolean(response) && !response.error;\n  if (response && response.error) followUpEmailError = String(response.error.message || response.error).slice(0, 500);\n} catch {}\nlet followUpStoppedByConversion = false;\nlet followUpStopReason = null;\ntry {\n  const stopped = $('Stop follow-up after conversion').first().json;\n  followUpStoppedByConversion = Boolean(stopped && stopped.followUpStoppedByConversion);\n  followUpStopReason = stopped && stopped.followUpStopReason;\n} catch {}\nreturn [{ json: {\n  ...delivery,\n  initialEmailAttempted: initial.initialEmailAttempted,\n  initialEmailSent: initial.initialEmailSent,\n  initialEmailError: initial.initialEmailError,\n  followUpEmailSent,\n  followUpEmailError,\n  followUpStoppedByConversion,\n  followUpStopReason,\n  remembered: delivery.isFixture ? null : id,\n  rememberedCount: store.handledCheckoutIds.length,\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3350,
        2320
      ],
      "id": "zvid-sac-0042",
      "name": "Remember checkout handled"
    },
    {
      "parameters": {
        "jsCode": "const result = $input.first().json;\nreturn [{ json: {\n  rendered: true,\n  fixture: result.isFixture,\n  checkoutId: result.checkoutId,\n  checkoutName: result.checkoutName,\n  itemCount: result.itemCount,\n  items: result.items,\n  cartTotal: result.cartTotal,\n  videoUrl: result.videoUrl,\n  jobId: result.jobId,\n  creditsCharged: result.creditsCharged,\n  initialEmailAttempted: result.initialEmailAttempted,\n  initialEmailSent: result.initialEmailSent,\n  initialEmailError: result.initialEmailError,\n  followUpEmailSent: result.followUpEmailSent,\n  followUpEmailError: result.followUpEmailError,\n  followUpStoppedByConversion: result.followUpStoppedByConversion,\n  followUpStopReason: result.followUpStopReason,\n  customerEmailExcludedFromVideo: true,\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        50,
        3000
      ],
      "id": "zvid-sac-0043",
      "name": "Recovery run summary"
    },
    {
      "parameters": {
        "url": "={{ $json.videoUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "data"
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        570,
        3000
      ],
      "id": "zvid-sac-0044",
      "name": "▶ Watch video",
      "onError": "continueRegularOutput",
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "id": "zvid-watch-ready",
      "name": "Video ready to watch?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        310,
        3000
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "zvid-watch-ready-condition",
              "leftValue": "={{ !$json.dryRun && /^https?:\\/\\//i.test(String(($json.videoUrl) || '')) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "publication-overview-shopify-abandoned-checkout-recovery",
      "name": "Workflow overview and setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -800,
        0
      ],
      "parameters": {
        "content": "## Recover abandoned Shopify checkouts\n\n### How it works\nFind one eligible checkout, wait, and recheck conversion and inventory before rendering a personalized video. Optional recovery emails include its link. A later recheck prevents follow-up after conversion or other disqualifying changes.\n\n### Setup\n1. Install **`@zvid/n8n-nodes-zvid`** from **Settings → Community nodes** before credentials. A workspace owner/admin may need to install it.\n2. Create a **Zvid API** credential with [your API key](https://app.zvid.io/api-keys) and Base URL `https://api.zvid.io`. Select it on every Zvid node.\n3. Set shopDomain and branding in Config. manualFixture=true uses sample data and short waits; switch it off for live Shopify.\n4. Attach X-Shopify-Access-Token Header Auth to all three Shopify requests. Required scopes are read_orders, read_customers and read_products, with protected checkout access. Refresh expiring tokens.\n5. SMTP is optional: connect both email nodes and configure emailFrom before setting sendEmail=true.\n6. **dryRun=false** renders and spends Zvid credits. Set it to **true** for an editor draft and quote.\n7. Test manually and review the output before activating automatic runs.\n\n### Customization\nAdjust branding, media and timing in the configuration node. [Detailed setup guide](https://github.com/Zvid-io/zvid-n8n/blob/master/workflows/shopify-abandoned-checkout-recovery.md).",
        "color": 1,
        "width": 720,
        "height": 1160
      }
    },
    {
      "id": "publication-section-shopify-abandoned-checkout-recovery-configure",
      "name": "1. Choose settings and an entry point",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "parameters": {
        "content": "## 1. Choose settings and an entry point\n\nComplete the overview setup first. Use the manual trigger for a test, then check the trigger settings and workflow timezone before activation.",
        "color": 7,
        "width": 880,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-abandoned-checkout-recovery-checkout",
      "name": "2. Find and lock one checkout",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1000,
        0
      ],
      "parameters": {
        "content": "## 2. Find and lock one checkout\n\nThe fixture avoids Shopify; live mode reads abandoned checkouts. One in-flight lock prevents concurrent recovery of the same checkout.",
        "color": 7,
        "width": 1140,
        "height": 760
      }
    },
    {
      "id": "publication-section-shopify-abandoned-checkout-recovery-recheck",
      "name": "3. Wait and recheck eligibility",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2260,
        0
      ],
      "parameters": {
        "content": "## 3. Wait and recheck eligibility\n\nCheck conversion, inventory, newer abandonment and prior Shopify email before rendering. Ineligible checkouts stop here.",
        "color": 7,
        "width": 1140,
        "height": 760
      }
    },
    {
      "id": "publication-section-shopify-abandoned-checkout-recovery-validate",
      "name": "4. Build and validate the design",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        920
      ],
      "parameters": {
        "content": "## 4. Build and validate the design\n\nPrepare the media and video design, then validate the payload and credit quote. Resolve validation errors before submitting a render.",
        "color": 7,
        "width": 880,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-abandoned-checkout-recovery-preview",
      "name": "5. Choose an optional editor preview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1000,
        920
      ],
      "parameters": {
        "content": "## 5. Choose an optional editor preview\n\ndryRun=true returns an editor draft and credit quote. The default false branch renders and spends Zvid credits; upstream AI services may charge separately.",
        "color": 7,
        "width": 880,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-abandoned-checkout-recovery-render",
      "name": "6. Render and wait for completion",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2000,
        920
      ],
      "parameters": {
        "content": "## 6. Render and wait for completion\n\nSubmit the render, then check its status until completion or timeout. Keep each result paired with its source item. Check Zvid before retrying a timed-out job.",
        "color": 7,
        "width": 1140,
        "height": 760
      }
    },
    {
      "id": "publication-section-shopify-abandoned-checkout-recovery-initial",
      "name": "7. Deliver the initial recovery message",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        1840
      ],
      "parameters": {
        "content": "## 7. Deliver the initial recovery message\n\nEmail, phone and the recovery URL stay out of the video payload. Enable SMTP delivery only after a private test.",
        "color": 7,
        "width": 1140,
        "height": 520
      }
    },
    {
      "id": "publication-section-shopify-abandoned-checkout-recovery-followup",
      "name": "8. Recheck before any follow-up",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1260,
        1840
      ],
      "parameters": {
        "content": "## 8. Recheck before any follow-up\n\nAfter the optional delay, check Shopify again. The fixture can simulate conversion to demonstrate that follow-up stops.",
        "color": 7,
        "width": 1140,
        "height": 760
      }
    },
    {
      "id": "publication-section-shopify-abandoned-checkout-recovery-stop",
      "name": "9. Stop after conversion or deliver once",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2520,
        1840
      ],
      "parameters": {
        "content": "## 9. Stop after conversion or deliver once\n\nSkip the follow-up when the checkout is no longer eligible. Record handling after the optional final email.",
        "color": 7,
        "width": 1140,
        "height": 760
      }
    },
    {
      "id": "zvid-watch-instructions",
      "name": "10. Review the finished media",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        2760
      ],
      "parameters": {
        "content": "## 10. Review the finished media\n\nOpen Watch video → Binary → data → View. Select each output item for batches; image outputs open as images. Empty links and preview results skip the download.",
        "color": 7,
        "width": 880,
        "height": 520
      }
    }
  ],
  "pinData": {},
  "connections": {
    "Test manually": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every 15 minutes": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Use manual fixture?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Use manual fixture?": {
      "main": [
        [
          {
            "node": "Create safe test checkout",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Find open abandoned checkouts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create safe test checkout": {
      "main": [
        [
          {
            "node": "Checkout found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find open abandoned checkouts": {
      "main": [
        [
          {
            "node": "Pick and lock one checkout",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick and lock one checkout": {
      "main": [
        [
          {
            "node": "Checkout found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Checkout found?": {
      "main": [
        [
          {
            "node": "Wait before recovery check",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing to recover",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait before recovery check": {
      "main": [
        [
          {
            "node": "Use fixture confirmation?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Use fixture confirmation?": {
      "main": [
        [
          {
            "node": "Approve checkout test fixture",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Confirm no purchase and inventory",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confirm no purchase and inventory": {
      "main": [
        [
          {
            "node": "Check conversion and inventory",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Approve checkout test fixture": {
      "main": [
        [
          {
            "node": "Still eligible after the wait?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check conversion and inventory": {
      "main": [
        [
          {
            "node": "Still eligible after the wait?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Still eligible after the wait?": {
      "main": [
        [
          {
            "node": "Build personalized recovery video",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Stop recovered or unavailable checkout",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build personalized recovery video": {
      "main": [
        [
          {
            "node": "Validate project (free)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate project (free)": {
      "main": [
        [
          {
            "node": "Check validation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check validation": {
      "main": [
        [
          {
            "node": "Dry run?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dry run?": {
      "main": [
        [
          {
            "node": "Save draft to editor",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Submit render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save draft to editor": {
      "main": [
        [
          {
            "node": "Dry run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Submit render": {
      "main": [
        [
          {
            "node": "Wait for render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait for render": {
      "main": [
        [
          {
            "node": "Get render status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get render status": {
      "main": [
        [
          {
            "node": "Render finished?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Render finished?": {
      "main": [
        [
          {
            "node": "Prepare private recovery delivery",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Still rendering?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Still rendering?": {
      "main": [
        [
          {
            "node": "Wait for render",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare private recovery delivery": {
      "main": [
        [
          {
            "node": "Send initial recovery email?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send initial recovery email?": {
      "main": [
        [
          {
            "node": "Send initial recovery email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Record initial delivery result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send initial recovery email": {
      "main": [
        [
          {
            "node": "Record initial delivery result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record initial delivery result": {
      "main": [
        [
          {
            "node": "Wait for a follow-up check?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait for a follow-up check?": {
      "main": [
        [
          {
            "node": "Wait before follow-up",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Remember checkout handled",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait before follow-up": {
      "main": [
        [
          {
            "node": "Use fixture follow-up?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Use fixture follow-up?": {
      "main": [
        [
          {
            "node": "Simulate conversion before follow-up",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Confirm before follow-up",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Simulate conversion before follow-up": {
      "main": [
        [
          {
            "node": "Still abandoned at follow-up?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confirm before follow-up": {
      "main": [
        [
          {
            "node": "Check follow-up conversion",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check follow-up conversion": {
      "main": [
        [
          {
            "node": "Still abandoned at follow-up?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Still abandoned at follow-up?": {
      "main": [
        [
          {
            "node": "Send follow-up email?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Stop follow-up after conversion",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send follow-up email?": {
      "main": [
        [
          {
            "node": "Send follow-up recovery email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Remember checkout handled",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send follow-up recovery email": {
      "main": [
        [
          {
            "node": "Remember checkout handled",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stop follow-up after conversion": {
      "main": [
        [
          {
            "node": "Remember checkout handled",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Remember checkout handled": {
      "main": [
        [
          {
            "node": "Recovery run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Recovery run summary": {
      "main": [
        [
          {
            "node": "Video ready to watch?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Video ready to watch?": {
      "main": [
        [
          {
            "node": "▶ Watch video",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "tags": []
}
