/* shared checkout — the money-touching machinery common to petalpost / pawpost /
   potionpost (and, next, picpost).

   Loaded as a `type="text/babel"` script BEFORE each site's app; exposes everything
   on `window.Checkout`. The DESIGN step, the per-product renderer, AND each step's
   panel markup + prose stay in each site's app — the panels' layouts/voices differ
   enough per site that sharing them was more indirection than it was worth. What we
   share is the stuff that's genuinely identical and/or risky to duplicate:

     • helpers       — emptyAddr / addrErrors / cleanAddr / isLight / wrapLines
     • loadArtImage  — rasterize an SVG element to a tightly alpha-cropped canvas
     • composeFront  — fill a 1875×2775 portrait canvas (the site draws its art layer
                       via a `drawPortrait` callback), rotate 90° into Lob's landscape
                       print PNG, derive the preview JPEG + echo PNG. The card is now
                       4×6 (same 2:3 proportions as the old 6×9), so we keep this
                       original high-res source and let Lob trim it to 4×6 (≈445 DPI)
     • Field         — one labelled text input (the one shared UI primitive)
     • useCheckout   — the hook: step state, address validation + Lob verify, the
                       PaymentIntent + Payment Element, promo retry, redirect-return,
                       localStorage, order create / send — i.e. all the Stripe/Lob
                       logic that previously lived (copy-pasted) in three files

   Each site supplies a config (copy, Stripe theme, localStorage key, buildPayload,
   renderComposite) and writes its OWN address form + review/payment markup, wired to
   the hook's returned state/handlers (`co.*`) and using `Checkout.Field`. */
(function () {
  const { useState, useRef, useEffect } = React;

  // ── validation + address helpers (verbatim across sites) ──
  const US_STATE = /^[A-Za-z]{2}$/;
  const ZIP = /^\d{5}(-\d{4})?$/;
  const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  const emptyAddr = () => ({ name: '', line1: '', line2: '', city: '', state: '', zip: '' });
  function addrErrors(a) {
    const e = {};
    if (!a.name.trim()) e.name = 1;
    if (!a.line1.trim()) e.line1 = 1;
    if (!a.city.trim()) e.city = 1;
    if (!US_STATE.test(a.state.trim())) e.state = 1;
    if (!ZIP.test(a.zip.trim())) e.zip = 1;
    return e;
  }
  const cleanAddr = (a) => Object.fromEntries(Object.entries(a).map(([k, v]) => [k, typeof v === 'string' ? v.trim() : v]));

  // ── paste-to-parse: turn a pasted blob into {name?,line1?,line2?,city?,state?,zip?} ──
  // Pure client-side, no API. Handles the common copy-paste sources (Apple/Google
  // Contacts, Gmail signatures, Maps, plain typed addresses) in multi-line OR
  // single-line/comma form. Anchors on a valid 2-letter state + ZIP, then reads the
  // city before it and the name/street lines above it. Returns ONLY the fields it's
  // confident about (so a partial paste fills what it can and leaves the rest); null
  // if it couldn't find anything useful. The caller spreads it over the existing
  // address, so a blank guess never wipes a field the user already typed.
  const US_STATES = new Set('AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY DC PR VI GU AS MP'.split(' '));
  const SECONDARY = /^(#|apt|apartment|unit|ste|suite|fl|floor|rm|room|bldg|building|dept|trlr|lot|spc|space|po box|p\.?\s?o\.?\s?box)\b/i;
  function parseAddress(raw) {
    if (!raw) return null;
    let text = String(raw).replace(/\r/g, '').replace(/\t/g, ' ').trim();
    if (!text) return null;
    // drop a trailing country line/segment + leading "address:" style labels
    text = text.replace(/[,\n]\s*(united states of america|united states|u\.?\s?s\.?\s?a\.?|usa|u\.?\s?s\.?)\.?\s*$/i, '').trim();
    text = text.replace(/^\s*(address|ship\s*to|mailing\s*address|deliver\s*to)\s*[:\-]\s*/i, '');

    const out = {};
    // anchor: last "<state> <zip>" where the 2-letter token is a real US state.
    const re = /\b([A-Za-z]{2})\.?[,\s]+(\d{5})(?:-(\d{4}))?\b/g;
    let m, found = null;
    while ((m = re.exec(text))) { if (US_STATES.has(m[1].toUpperCase())) found = m; }
    let head = text;
    if (found) {
      out.state = found[1].toUpperCase();
      out.zip = found[3] ? found[2] + '-' + found[3] : found[2];
      const before = text.slice(0, found.index).replace(/[,\s]+$/, '');
      const parts = before.split(/[,\n]/);
      const city = (parts.pop() || '').trim();
      if (city && !/\d/.test(city)) out.city = city; // a real city has no digits
      head = parts.join('\n').trim();
    } else {
      const z = text.match(/\b(\d{5})(?:-(\d{4}))?\s*$/); // fall back to a lone trailing ZIP
      if (z) { out.zip = z[2] ? z[1] + '-' + z[2] : z[1]; head = text.slice(0, z.index).replace(/[,\s]+$/, '').trim(); }
    }

    // head now holds name + street (+ maybe a unit line). Split into segments.
    let segs = head.split('\n').map((s) => s.trim()).filter(Boolean);
    if (segs.length <= 1 && head.includes(',')) segs = head.split(',').map((s) => s.trim()).filter(Boolean);
    if (segs.length) {
      // street = first segment with a house number (or a PO box); else the last one
      let si = segs.findIndex((s) => /\d/.test(s) || /\bp\.?\s?o\.?\s?box\b/i.test(s));
      if (si === -1) si = segs.length - 1;
      out.line1 = segs[si];
      if (segs[si + 1] && SECONDARY.test(segs[si + 1])) out.line2 = segs[si + 1]; // unit on its own line
      for (let i = 0; i < si; i++) { if (!/\d/.test(segs[i])) { out.name = segs[i]; break; } } // name = first digitless line above street
      if (!out.name && si > 0) out.name = segs[0];
    }
    // pull an inline unit out of line1 ("123 Maple St Apt 4B") when we don't have one yet
    if (out.line1 && !out.line2) {
      const um = out.line1.match(/\s+(#\s*\w+.*|(?:apt|apartment|unit|ste|suite|fl|floor|rm|room|bldg|building|trlr|lot|spc|space)\b\.?\s*\S.*)$/i);
      if (um) { out.line2 = um[1].trim(); out.line1 = out.line1.slice(0, um.index).trim(); }
    }
    return Object.keys(out).length ? out : null;
  }

  // One shared paste box (self-styled with restrained, color-inheriting inline styles
  // so it sits in any of the four sites without touching their stylesheets). Auto-fills
  // on paste; also offers a manual "fill" button. Reports which fields it caught.
  function PasteAddress({ onParsed, label, placeholder }) {
    const [val, setVal] = useState('');
    const [note, setNote] = useState('');
    const nameRef = useRef('pa' + Math.random().toString(36).slice(2, 9)); // stable across renders
    function apply(text) {
      const p = parseAddress(text);
      if (!p) { setNote('couldn’t read that — type it in below'); return; }
      onParsed(p);
      setNote('filled: ' + Object.keys(p).join(', '));
    }
    const ta = { width: '100%', minHeight: 60, padding: '10px 12px', borderRadius: 10, border: '1px solid rgba(128,128,128,.35)', background: 'rgba(128,128,128,.06)', font: 'inherit', fontSize: 16, lineHeight: 1.4, color: 'inherit', resize: 'vertical', boxSizing: 'border-box' };
    return React.createElement('div', { className: 'pasteaddr', style: { marginBottom: 14 } },
      React.createElement('label', { style: { display: 'block', fontSize: 12, opacity: .7, marginBottom: 5, letterSpacing: '.04em' } }, label || 'Paste an address'),
      React.createElement('textarea', {
        value: val, placeholder: placeholder || 'Paste a full address here — we’ll sort it into the fields below', rows: 3, style: ta,
        // Suppress browser/password-manager autofill so it can't stomp the parsed fields.
        name: nameRef.current, autoComplete: 'off', autoCorrect: 'off',
        autoCapitalize: 'off', spellCheck: false, 'data-1p-ignore': 'true', 'data-lpignore': 'true',
        'data-bwignore': 'true', 'data-form-type': 'other',
        onChange: (e) => setVal(e.target.value),
        onPaste: (e) => { const t = (e.clipboardData || window.clipboardData).getData('text'); if (t && t.trim()) { e.preventDefault(); setVal(t); apply(t); } },
      }),
      React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 6 } },
        React.createElement('button', { type: 'button', onClick: () => apply(val), style: { font: 'inherit', fontSize: 13, padding: '5px 12px', borderRadius: 8, border: '1px solid currentColor', background: 'transparent', color: 'inherit', opacity: .8, cursor: 'pointer' } }, 'Fill fields'),
        note && React.createElement('span', { style: { fontSize: 12, opacity: .6, fontStyle: 'italic' } }, note)
      )
    );
  }

  // Is a card tint light? Most tints are dark, but parchment/cream/bone are light —
  // on those the note/signature need dark ink to read on both the live + printed card.
  function isLight(hex) {
    const h = String(hex || '').replace('#', '');
    const n = parseInt(h.length === 3 ? h.replace(/./g, (c) => c + c) : h, 16);
    if (Number.isNaN(n)) return false;
    const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
    return (r * 299 + g * 587 + b * 114) / 1000 > 140;
  }

  // Greedy word-wrap against a canvas 2d context, honouring explicit newlines.
  function wrapLines(ctx, text, maxW) {
    const out = [];
    (text || '').split('\n').forEach((para) => {
      let line = '';
      para.split(/\s+/).forEach((wd) => {
        const t = line ? line + ' ' + wd : wd;
        if (ctx.measureText(t).width > maxW && line) { out.push(line); line = wd; }
        else line = t;
      });
      out.push(line);
    });
    return out;
  }

  // Rasterize a live SVG element to a canvas, tightly cropped to its drawn content
  // via an alpha scan (robust where getBBox is unreliable). Returns Promise<canvas|null>.
  function loadArtImage(svg, targetW = 1400, marginFrac = 0.03, alphaThreshold = 18) {
    if (!svg) return Promise.resolve(null);
    const vb = (svg.getAttribute('viewBox') || '0 0 320 420').split(/\s+/).map(Number);
    const ratio = (vb[3] || 420) / (vb[2] || 320);
    const w = targetW, h = Math.round(targetW * ratio);
    const clone = svg.cloneNode(true);
    clone.setAttribute('width', w); clone.setAttribute('height', h);
    clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
    const xml = new XMLSerializer().serializeToString(clone);
    const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(xml);
    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => {
        const full = document.createElement('canvas'); full.width = w; full.height = h;
        const fx = full.getContext('2d'); fx.drawImage(img, 0, 0, w, h);
        let minX = w, minY = h, maxX = 0, maxY = 0, found = false;
        try {
          const d = fx.getImageData(0, 0, w, h).data;
          for (let y = 0; y < h; y++) for (let xx = 0; xx < w; xx++) {
            if (d[(y * w + xx) * 4 + 3] > alphaThreshold) {
              found = true;
              if (xx < minX) minX = xx; if (xx > maxX) maxX = xx;
              if (y < minY) minY = y; if (y > maxY) maxY = y;
            }
          }
        } catch (e) { /* tainted — fall back to full */ }
        if (!found) return resolve(full);
        const m = Math.round(targetW * marginFrac);
        minX = Math.max(0, minX - m); minY = Math.max(0, minY - m);
        maxX = Math.min(w - 1, maxX + m); maxY = Math.min(h - 1, maxY + m);
        const cw2 = maxX - minX + 1, ch2 = maxY - minY + 1;
        const crop = document.createElement('canvas'); crop.width = cw2; crop.height = ch2;
        crop.getContext('2d').drawImage(full, minX, minY, cw2, ch2, 0, 0, cw2, ch2);
        resolve(crop);
      };
      img.onerror = () => resolve(null);
      img.src = url;
    });
  }

  // Compose the printable front and produce the three images the order pipe wants.
  //   • bgHex / fonts / loadArt / drawPortrait / dims / echoWidth — see header.
  //   • imageType / imageQuality — encoding for the big print `image`. Defaults to
  //     PNG (flat vector art compresses tiny). PHOTO sites must pass
  //     `imageType:'image/jpeg', imageQuality:~0.9` — a 2775×1875 photo PNG is
  //     5–15 MB and blows the 6 MB order-POST cap; JPEG keeps it ~1 MB.
  //   • landscape — most cards are tall art composed portrait then rotated 90° onto
  //     Lob's landscape sheet (recipient turns the card). A natively-landscape card
  //     (e.g. a picpost landscape photo) sets `landscape:true`: compose directly on
  //     the 2775×1875 sheet, no rotation. Default false (portrait + rotate).
  // drawPortrait(ctx, env), env = { art, dims, light, bgHex, wrapLines, landscape }.
  // Returns { image: landscape print PNG/JPEG, preview: upright JPEG, bouquet: echo PNG }.
  async function composeFront({ bgHex, fonts = [], loadArt, drawPortrait, dims, echoWidth = 600, imageType = 'image/png', imageQuality, landscape = false }) {
    let { PR_W = 1875, PR_H = 2775, PR_PAD = 112 } = dims || {};
    if (landscape) { const t = PR_W; PR_W = PR_H; PR_H = t; } // compose on the landscape sheet directly
    try {
      for (const f of fonts) await document.fonts.load(f);
      await document.fonts.ready;
    } catch (e) {}
    const art = (loadArt ? await loadArt() : null);
    const P = document.createElement('canvas'); P.width = PR_W; P.height = PR_H;
    const x = P.getContext('2d');
    x.fillStyle = bgHex; x.fillRect(0, 0, PR_W, PR_H);
    const left = PR_PAD, right = PR_W - PR_PAD, top = PR_PAD, bottom = PR_H - PR_PAD, cw = right - left;
    drawPortrait(x, { art, dims: { PR_W, PR_H, PR_PAD, left, right, top, bottom, cw }, light: isLight(bgHex), bgHex, wrapLines, landscape });

    // the print image: portrait compositions rotate 90° onto the landscape sheet;
    // a natively-landscape composition is already upright on it.
    let printCanvas;
    if (landscape) {
      printCanvas = P;
    } else {
      printCanvas = document.createElement('canvas'); printCanvas.width = PR_H; printCanvas.height = PR_W; // e.g. 2775×1875
      const lx = printCanvas.getContext('2d');
      lx.translate(printCanvas.width, 0); lx.rotate(Math.PI / 2); lx.drawImage(P, 0, 0);
    }
    // upright, downscaled preview JPEG for the confirmation email
    const V = document.createElement('canvas'); V.width = 700; V.height = Math.round(700 * PR_H / PR_W);
    V.getContext('2d').drawImage(P, 0, 0, V.width, V.height);
    // standalone cropped art (transparent) — echoed small on the mailing-side back
    let echoPng = null;
    if (art) {
      const B = document.createElement('canvas');
      B.width = echoWidth; B.height = Math.round(echoWidth * art.height / art.width);
      B.getContext('2d').drawImage(art, 0, 0, B.width, B.height);
      echoPng = B.toDataURL('image/png');
    }
    return { image: printCanvas.toDataURL(imageType, imageQuality), preview: V.toDataURL('image/jpeg', 0.86), bouquet: echoPng };
  }

  // Load an image from a URL or data URL onto an <img>, resolving null on error.
  // composeBack uses it for the art echo (data URL) + the brand logo/QR SVGs (served
  // same-origin from /shared and /api, so the canvas stays untainted and toDataURL works).
  function loadUrlImage(src) {
    if (!src) return Promise.resolve(null);
    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => resolve(img);
      img.onerror = () => resolve(null);
      img.src = src;
    });
  }

  // Compose a CLEAN back-of-card image for the emails — the *content* side the buyer
  // designed (a written note, OR the art echo + inventory line, plus the brand mark /
  // caption / QR), WITHOUT Lob's auto-printed mailing block (recipient address, USPS
  // barcode, postage). Mirrors buildPostcardBackHtml's two columns + the in-app
  // .backcard preview, so the email matches what the buyer saw. Landscape 3:2 (the
  // back is never rotated). Returns a downscaled JPEG data URL, or null on any failure
  // (the emails just omit the image then — best-effort, never blocks an order).
  // spec = {
  //   bgHex, echoPng,                       // card colour + standalone art echo (data URL)
  //   fonts: [],                            // webfonts the note/mark/caption need
  //   left: { kind:'note'|'art'|'recipe', text, fontCss, italic, invLabel, items:[] },
  //   mark: { word1, word2 },               // wordmark, e.g. petal / post.io
  //   caption, glyph,                       // "scan to …" + a small leading glyph
  //   logoUrl, qrUrl,                       // same-origin SVG assets (both optional)
  // }
  async function composeBack(spec) {
    try {
      const { bgHex = '#f3ecd9', echoPng = null, fonts = [], left = {}, mark = {},
              caption = '', glyph = '', qrUrl = '' } = spec || {};
      try { for (const f of fonts) await document.fonts.load(f); await document.fonts.ready; } catch (e) {}
      const W = 1500, H = 1000;                 // 3:2 — the 4×6 back, upright
      const light = isLight(bgHex);
      const ink   = light ? '#2a241b' : '#ece1c4';
      const sub   = light ? '#7a6e58' : '#bdae8e';
      const label = light ? '#b09a7c' : '#9a8a66';
      const rose  = light ? '#a8606a' : '#d98a93';
      const [echo, qr] = await Promise.all([loadUrlImage(echoPng), loadUrlImage(qrUrl)]);

      const C = document.createElement('canvas'); C.width = W; C.height = H;
      const x = C.getContext('2d');
      x.fillStyle = bgHex; x.fillRect(0, 0, W, H);

      const divX = Math.round(W * 0.54);        // dashed column divider (matches .bc-right)
      x.save();
      x.strokeStyle = light ? 'rgba(60,40,20,.20)' : 'rgba(236,225,196,.22)';
      x.lineWidth = 2; x.setLineDash([7, 7]);
      x.beginPath(); x.moveTo(divX, H * 0.08); x.lineTo(divX, H * 0.92); x.stroke();
      x.restore();

      const lpad = Math.round(W * 0.05);
      const lw   = divX - lpad - Math.round(W * 0.035);   // left content width

      // ── LEFT column ──────────────────────────────────────────────────────
      x.textBaseline = 'alphabetic';
      if (left.kind === 'note' || left.kind === 'recipe') {
        const serif = left.kind === 'recipe';
        const fam = left.fontCss || "'Newsreader', Georgia, serif";
        const ital = left.italic || serif;
        const text = (left.text || '').trim();
        let fs = 46, lines = [];
        for (; fs >= 22; fs -= 2) {
          x.font = `${ital ? 'italic ' : ''}500 ${fs}px ${fam}`;
          lines = wrapLines(x, text, lw);
          if (lines.length * Math.round(fs * 1.5) <= H * 0.78) break;
        }
        const lh = Math.round(fs * 1.5);
        x.textAlign = 'left'; x.fillStyle = serif ? sub : ink;
        // recipe sits low (grows upward); a written note starts near the top
        const y0 = serif ? Math.round(H * 0.88 - (lines.length - 1) * lh) : Math.round(H * 0.12 + fs);
        lines.forEach((ln, i) => x.fillText(ln, lpad, y0 + i * lh));
      } else {
        if (echo) {
          const maxW = Math.round(W * 0.34), maxH = Math.round(H * 0.62);
          let dw = maxW, dh = Math.round(maxW * echo.height / echo.width);
          if (dh > maxH) { dh = maxH; dw = Math.round(maxH * echo.width / echo.height); }
          x.drawImage(echo, lpad, Math.round(H * 0.07), dw, dh);
        }
        const items = [...new Set(left.items || [])].join('  ·  ');
        if (items) {
          x.textAlign = 'left';
          x.fillStyle = label; x.font = "400 22px 'JetBrains Mono', monospace";
          if ('letterSpacing' in x) x.letterSpacing = '2px';
          x.fillText((left.invLabel || '').toUpperCase(), lpad, Math.round(H * 0.82));
          if ('letterSpacing' in x) x.letterSpacing = '0px';
          x.fillStyle = sub; x.font = "26px 'Newsreader', Georgia, serif";
          wrapLines(x, items, lw).slice(0, 3).forEach((ln, i) => x.fillText(ln, lpad, Math.round(H * 0.87) + i * 34));
        }
      }

      // ── RIGHT column: wordmark · caption · QR, centred on one axis ─────────
      const rcx = Math.round(divX + (W - divX) / 2);
      const my = Math.round(H * 0.22);
      const w1 = mark.word1 || '', w2 = mark.word2 || '';
      x.font = "italic 46px 'Newsreader', Georgia, serif"; x.textBaseline = 'alphabetic';
      const w1w = x.measureText(w1).width, w2w = x.measureText(w2).width;
      const sx = rcx - (w1w + w2w) / 2;
      x.textAlign = 'left';
      x.fillStyle = rose; x.fillText(w1, sx, my);
      x.fillStyle = ink;  x.fillText(w2, sx + w1w, my);
      x.textAlign = 'center'; x.fillStyle = sub; x.font = "italic 26px 'Newsreader', Georgia, serif";
      x.fillText((glyph ? glyph + ' ' : '') + caption, rcx, my + 50);
      if (qr) {
        const qs = Math.round(W * 0.17);
        x.drawImage(qr, Math.round(rcx - qs / 2), my + 84, qs, qs);
      }

      return C.toDataURL('image/jpeg', 0.85);
    } catch (e) { return null; }
  }

  // ── the one shared UI primitive: a labelled text field ──
  function Field({ label, value, onChange, err, placeholder, maxLength }) {
    return (
      <div className="field">
        <label>{label}</label>
        <input className={err ? 'bad' : ''} value={value} placeholder={placeholder}
          maxLength={maxLength} onChange={(e) => onChange(e.target.value)} />
      </div>
    );
  }

  // Street field with Lob-powered autocomplete (shared by all sites): type a
  // prefix, pick a real US address, and onPick fills line1/city/state/zip in one
  // go. Falls back to a plain field if the proxy returns nothing (no key / no
  // matches) — typing is never blocked. Markup matches Field (.field > label +
  // input) plus an .acwrap/.aclist dropdown each site styles in its own palette.
  function StreetField({ label, value, err, placeholder, maxLength, onChange, onPick, ctx }) {
    const [sugs, setSugs] = useState([]);
    const [open, setOpen] = useState(false);
    const [active, setActive] = useState(-1);
    const boxRef = useRef(null);
    const tRef = useRef(null);
    const skipRef = useRef(false); // don't re-query the prefix we just filled
    // Stable per-instance id + a nonce name. The random name keeps Chrome from
    // matching this to a saved "address-line1" profile; the combobox ARIA below
    // tells the browser this field is app-managed so it suppresses its own autofill.
    const acIdRef = useRef('ac' + Math.random().toString(36).slice(2, 9));
    useEffect(() => {
      function onDoc(e) { if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false); }
      document.addEventListener('mousedown', onDoc);
      return () => { document.removeEventListener('mousedown', onDoc); clearTimeout(tRef.current); };
    }, []);
    function handleChange(v) {
      onChange(v);
      if (skipRef.current) { skipRef.current = false; return; }
      clearTimeout(tRef.current);
      if (v.trim().length < 3) { setSugs([]); setOpen(false); return; }
      tRef.current = setTimeout(async () => {
        // Pass any city/state/zip the buyer has already entered so Lob narrows the
        // suggestions to their region (the only geo lever we have through the proxy).
        const list = await autocompleteAddress(v, ctx || {});
        setSugs(list); setOpen(list.length > 0); setActive(-1);
      }, 250);
    }
    function pick(s) { skipRef.current = true; onPick(s); setOpen(false); setSugs([]); setActive(-1); }
    function onKey(e) {
      if (!open || !sugs.length) return;
      if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(a + 1, sugs.length - 1)); }
      else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
      else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); pick(sugs[active]); }
      else if (e.key === 'Escape') { setOpen(false); }
    }
    return (
      <div className="field acwrap" ref={boxRef}>
        <label>{label}</label>
        <input className={err ? 'bad' : ''} value={value} placeholder={placeholder}
          maxLength={maxLength}
          name={acIdRef.current}
          autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false}
          role="combobox" aria-autocomplete="list" aria-expanded={open}
          aria-controls={acIdRef.current + '-list'}
          aria-activedescendant={active >= 0 ? acIdRef.current + '-opt-' + active : undefined}
          data-1p-ignore="true" data-lpignore="true" data-bwignore="true" data-form-type="other"
          onChange={(e) => handleChange(e.target.value)}
          onKeyDown={onKey} onFocus={() => { if (sugs.length) setOpen(true); }} />
        {open && (
          <ul className="aclist" id={acIdRef.current + '-list'} role="listbox">
            {sugs.map((s, i) => (
              <li key={i} id={acIdRef.current + '-opt-' + i} role="option" aria-selected={i === active}
                className={i === active ? 'on' : ''}
                onMouseEnter={() => setActive(i)}
                onMouseDown={(e) => { e.preventDefault(); pick(s); }}>
                <span className="ac-st">{s.line1}</span>
                <span className="ac-ci">{s.city}, {s.state} {s.zip}</span>
              </li>
            ))}
          </ul>
        )}
      </div>
    );
  }

  // Always-visible Google sign-in + admin free-send control. Self-styled (inherits
  // colour/font) so it sits in any site's header without touching its stylesheet.
  // Pass the hook's `co.admin`. Lazy-loads the GIS script, renders Google's button
  // when signed out, and a "free send" toggle + sign-out when signed in as an admin.
  // (A signed-in non-admin just sees a note — the door is server-side regardless.)
  // This is also the natural home for a future buyer sign-in / saved-recipients UI.
  function AdminControls({ admin }) {
    const elRef = useRef(null);
    const [ready, setReady] = useState(!!(window.google && window.google.accounts && window.google.accounts.id));
    const clientId = admin && admin.clientId;

    // Load Google Identity Services once (no per-site <script> tag needed).
    useEffect(() => {
      if (!clientId || ready) return;
      let s = document.getElementById('gis-client');
      const onload = () => setReady(true);
      if (!s) {
        s = document.createElement('script');
        s.id = 'gis-client'; s.src = 'https://accounts.google.com/gsi/client'; s.async = true; s.defer = true;
        document.head.appendChild(s);
      }
      s.addEventListener('load', onload);
      return () => s.removeEventListener('load', onload);
    }, [clientId, ready]);

    // Init + render Google's button when signed out.
    useEffect(() => {
      if (!ready || !clientId || (admin && admin.user)) return;
      try {
        window.google.accounts.id.initialize({ client_id: clientId, callback: (resp) => admin.onCredential(resp && resp.credential) });
        if (elRef.current) {
          elRef.current.innerHTML = '';
          window.google.accounts.id.renderButton(elRef.current, { type: 'standard', theme: 'outline', size: 'small', text: 'signin', shape: 'pill' });
        }
      } catch (e) {}
    }, [ready, clientId, admin && admin.user]);

    if (!clientId) return null; // sign-in not configured on the server
    const wrap = { display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, fontStyle: 'italic', opacity: .9, flexWrap: 'wrap' };
    const btn = { font: 'inherit', fontSize: 12, padding: '3px 10px', borderRadius: 8, border: '1px solid currentColor', background: 'transparent', color: 'inherit', opacity: .7, cursor: 'pointer' };
    if (admin.user) {
      return React.createElement('div', { className: 'admin-controls', style: wrap },
        admin.isAdmin
          ? React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' } },
              React.createElement('input', { type: 'checkbox', checked: admin.freeMode, onChange: (e) => admin.setFreeMode(e.target.checked) }),
              'free send (admin)')
          : React.createElement('span', { style: { opacity: .7 } }, 'signed in'),
        React.createElement('span', { style: { opacity: .6 } }, admin.user.email),
        React.createElement('button', { type: 'button', onClick: admin.signOut, style: btn }, 'sign out')
      );
    }
    return React.createElement('div', { className: 'admin-controls', style: wrap },
      admin.note && React.createElement('span', { style: { opacity: .7 } }, admin.note),
      React.createElement('div', { ref: elRef })
    );
  }

  const DEFAULT_MESSAGES = {
    startPayFail: 'could not start payment.',
    placeFail: 'could not place the order.',
    network: 'network error — please try again.',
    payFail: 'payment could not be completed.',
    verifyFail: 'we couldn’t verify that recipient address.',
  };

  // ── the hook: owns step + address + payment state and the order pipe ──
  // opts = { price, designStep, sentStep, localStorageKey, stripeAppearance, returnUrl,
  //          buildPayload, renderComposite, onPayError, messages }
  //   • buildPayload()    → product-specific order fields ({ potion|pet|…, note, from, bg })
  //   • renderComposite() → async () => { image, preview, bouquet }
  function useCheckout(opts) {
    const {
      price = 7,
      designStep = 'design',
      sentStep = 'sent',
      localStorageKey = 'pp_ret',
      stripeAppearance,
      returnUrl = window.location.origin + '/',
      buildPayload,
      renderComposite,
      onPayError,
      messages,
      // Optional Celebration (group-card shower) capability. When set, the
      // contributor mails ONE card to a fixed honoree the SERVER fills in: we skip
      // recipient-address entry/validation, omit to/tos/ret from the order body, and
      // carry { campaignId, idToken } instead. Fully gated — when `campaign` is
      // undefined the hook behaves exactly as before for the other three sites.
      //   campaign = { id, getIdToken: () => string|null }
      campaign = null,
    } = opts;
    const msg = Object.assign({}, DEFAULT_MESSAGES, messages);

    const [step, setStep] = useState(designStep);

    // address state — `to` is recipient #1 (every site's markup is wired to it);
    // extraTos holds recipients 2..N for multi-recipient orders (same card, one
    // checkout, N separate postcards). Sites without a multi-recipient UI just
    // never call addRecipient and behave exactly as before.
    const [to, setTo] = useState(emptyAddr());
    const [extraTos, setExtraTos] = useState([]);
    const [ret, setRet] = useState(emptyAddr());
    const [useOwnReturn, setUseOwnReturn] = useState(false);
    const [email, setEmail] = useState('');
    const [showErrors, setShowErrors] = useState(false);
    const [sendError, setSendError] = useState('');

    // payment state
    const [payEnabled, setPayEnabled] = useState(false);
    const [clientSecret, setClientSecret] = useState('');
    const [creatingOrder, setCreatingOrder] = useState(false);
    const [paying, setPaying] = useState(false);
    const [verifying, setVerifying] = useState(false);
    const [orderId, setOrderId] = useState('');
    const [payAmount, setPayAmount] = useState(price * 100);
    const [promo, setPromoRaw] = useState('');
    // Promo codes are case-insensitive and whitespace-free (server matches on
    // trim().toUpperCase()). Normalize at the input source — every site wires its
    // own code field to this setter, so cleaning here fixes all of them at once:
    // a pasted "  spring25 " (or with stray tabs/newlines) shows + sends as SPRING25.
    // Free-gift-code state: freeApplied flips true when Apply confirms the code is a
    // $0 code (see applyPromo → /api/promo/check). It suppresses the Payment Element
    // and turns the final CTA into a "place free card" hold. freeRemaining is the
    // code's remaining card budget, for a friendly message. Editing the code text
    // clears the free state (below) so a stale free CTA never lingers on a changed code.
    const [freeApplied, setFreeApplied] = useState(false);
    const [freeRemaining, setFreeRemaining] = useState(null);
    const setPromo = (v) => {
      setPromoRaw(String(v == null ? '' : v).replace(/\s+/g, '').toUpperCase());
      if (freeApplied) { setFreeApplied(false); setFreeRemaining(null); setPromoApplied(false); setPayAmount(price * 100); setClientSecret(''); }
    };
    const [promoApplied, setPromoApplied] = useState(false);
    const [promoTried, setPromoTried] = useState(false);
    const pubKeyRef = useRef(null);
    const stripeRef = useRef(null);
    const testMode = (pubKeyRef.current || '').startsWith('pk_test_');
    // Covered-card (prepaid credit) state. coverWantedRef tracks whether the next
    // order-create should request the free covered path; it flips to false once the
    // pool runs out so the contributor falls back to the normal pay flow. coveredFellBack
    // surfaces a gentle "the covered cards just ran out" message to the site.
    const coverWantedRef = useRef(false);
    const [coveredFellBack, setCoveredFellBack] = useState(false);

    // ── admin free-send (Google sign-in) ──
    // An allowlisted admin can mail a card without paying. Security is 100%
    // server-side: /api/order/admin-free re-verifies the Google token + the
    // ADMIN_EMAILS allowlist, so none of this client state can grant access —
    // it only decides which button to show. isAdminFree = signed-in admin who
    // hasn't toggled free-send off (defaults on, since that's the whole point).
    const [googleClientId, setGoogleClientId] = useState(null);
    const [adminUser, setAdminUser] = useState(null); // { email, name } once signed in
    const [isAdmin, setIsAdmin] = useState(false);
    const [freeMode, setFreeMode] = useState(true);
    const [adminNote, setAdminNote] = useState('');
    const adminTokenRef = useRef(null);
    const isAdminFree = isAdmin && freeMode;

    // remembered return address (per-site localStorage key)
    useEffect(() => { try { const s = localStorage.getItem(localStorageKey); if (s) setRet(JSON.parse(s)); } catch (e) {} }, []);
    useEffect(() => { try { localStorage.setItem(localStorageKey, JSON.stringify(ret)); } catch (e) {} }, [ret]);

    useEffect(() => { window.scrollTo(0, 0); }, [step]);

    // Fetch the publishable key once; if present, switch on the real pay flow.
    useEffect(() => {
      fetch('/api/config').then((r) => r.json()).then((c) => {
        if (c.stripePublishableKey && window.Stripe) { pubKeyRef.current = c.stripePublishableKey; setPayEnabled(true); }
        if (c.googleClientId) setGoogleClientId(c.googleClientId);
      }).catch(() => {});
    }, []);

    // If a wallet / 3-D Secure redirect brought us back, land on the sent screen.
    useEffect(() => {
      const q = new URLSearchParams(window.location.search);
      if (q.get('redirect_status') === 'succeeded') {
        setStep(sentStep);
        // Strip the Stripe redirect params, but in campaign mode keep ?c=<id> so a
        // post-payment reload still has the campaign context.
        const keep = campaign && campaign.id ? `?c=${encodeURIComponent(campaign.id)}` : '';
        window.history.replaceState({}, '', window.location.pathname + keep);
      }
    }, []);

    // Open a PaymentIntent when we reach review (persists the order + art, returns a secret).
    // Skip it for an admin in free-send mode — there's no charge to prepare (and we
    // don't want a live PaymentIntent sitting around). Toggling free-send off on the
    // review screen re-triggers this and brings up the real payment form.
    // Two-phase review (non-campaign): we DON'T auto-create here — the buyer first
    // confirms their tracking email on the review step, then the "continue to payment"
    // CTA (goNext) calls createOrder with that email, so the order always carries the
    // email the buyer actually submitted (no autofill collisions on the address step,
    // no captured-then-edited race). Campaign mode has no email field (the server uses
    // the signed-in identity), so it keeps creating instantly on review entry.
    useEffect(() => {
      // Covered mode owns its own create trigger below (no PaymentIntent), so don't
      // double-fire here when a credit is covering this card.
      if (step === 'review' && payEnabled && !clientSecret && !creatingOrder && !isAdminFree && !coverWantedRef.current && campaign) createOrder();
    }, [step, payEnabled, isAdminFree]);

    // Covered (prepaid-credit) mode: when the host has covered cards, the contributor
    // pays nothing — the order is created (and held server-side) on the review step's
    // CTA, with NO Payment Element. Sync the request flag from the site's covered
    // signal; the actual createOrder fires from goNext (the "send my card" CTA). If the
    // pool ran out the server tells us and we fall back to pay (coverWantedRef flips off,
    // coveredFellBack lights up, and createOrder re-runs to mount the Payment Element).
    const campaignCovered = !!(campaign && typeof campaign.covered === 'function' && campaign.covered());
    useEffect(() => { if (campaignCovered && !coveredFellBack) coverWantedRef.current = true; }, [campaignCovered, coveredFellBack]);

    // Mount the Payment Element once we have a client secret.
    useEffect(() => {
      if (step !== 'review' || !clientSecret || !pubKeyRef.current || !window.Stripe) return;
      const stripe = window.Stripe(pubKeyRef.current);
      const elements = stripe.elements({ clientSecret, appearance: stripeAppearance });
      const paymentEl = elements.create('payment', { layout: 'tabs' });
      paymentEl.mount('#payment-element');
      stripeRef.current = { stripe, elements };
      return () => { try { paymentEl.unmount(); } catch (e) {} stripeRef.current = null; };
    }, [step, clientSecret]);

    // ── multiple recipients (one card design → N postcards, N × price) ──
    const MAX_RECIPIENTS = 5;
    const tos = [to, ...extraTos];
    const setRecipient = (i, a) => { i === 0 ? setTo(a) : setExtraTos((xs) => xs.map((x, j) => (j === i - 1 ? a : x))); };
    const addRecipient = () => setExtraTos((xs) => (xs.length + 1 < MAX_RECIPIENTS ? [...xs, emptyAddr()] : xs));
    const removeRecipient = (i) => { if (i > 0) setExtraTos((xs) => xs.filter((_, j) => j !== i - 1)); };

    // address validation
    const tosErr = tos.map(addrErrors);
    const toErr = tosErr[0];
    const retErr = addrErrors(ret);
    const emailValid = EMAIL.test(email.trim());
    // Campaign mode: the contributor enters NO recipient address (the server fills
    // the honoree's) and NO tracking email (the server uses their verified Google
    // email). The step is gated on sign-in alone, enforced separately via the CTA.
    // The tracking email is no longer collected here — it moved to the review step
    // (see the two-phase note above), so the address step gates on the address(es) +
    // (opt-in) return address only. emailValid still gates the review CTA via goNext.
    const addrReady = campaign
      ? true
      : tosErr.every((e) => Object.keys(e).length === 0) && (!useOwnReturn || Object.keys(retErr).length === 0);
    const errors = showErrors
      ? { to: toErr, tos: tosErr, ret: retErr, email: emailValid ? null : 1 }
      : { to: {}, tos: tos.map(() => ({})), ret: {}, email: null };

    const addrFields = () => {
      // Campaign mode: omit to/tos/ret AND email (the server fills the recipient and
      // uses the contributor's verified Google email), carrying only the identity +
      // the campaign id. When the host has prepaid the pool (covered), ask the server
      // for the free covered path (no PaymentIntent) — unless we've already fallen
      // back to pay because the pool ran out.
      if (campaign) return { campaignId: campaign.id, idToken: campaign.getIdToken(),
        ...(coverWantedRef.current ? { coverRequest: true } : {}) };
      return {
        to: cleanAddr(tos[0]),
        // extra recipients ride along only when there are any — a single-recipient
        // order keeps the exact payload shape it always had.
        ...(tos.length > 1 ? { tos: tos.map(cleanAddr) } : {}),
        ret: useOwnReturn ? cleanAddr(ret) : emptyAddr(),
        email: email.trim(),
      };
    };

    async function createOrder() {
      setSendError('');
      // Campaign contributions require a signed-in identity before we hit the server.
      // A site-wide session cookie counts: prefer the host's isSignedIn() predicate
      // (true when restored from the pp_session cookie, even with no live idToken);
      // fall back to the legacy idToken presence check.
      const campaignSignedIn = campaign && (campaign.isSignedIn ? campaign.isSignedIn() : !!campaign.getIdToken());
      if (campaign && !campaignSignedIn) { setSendError('Please sign in to add your card.'); return; }
      setCreatingOrder(true);
      try {
        const { image, preview, bouquet, previewBack } = await renderComposite();
        const res = await fetch('/api/order/create', {
          method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(Object.assign({}, buildPayload(), { image, preview, bouquet, previewBack, promo: promo.trim() }, addrFields())),
        });
        const data = await res.json().catch(() => ({}));
        if (!res.ok) {
          if (res.status === 422 && data.field === 'to') { setSendError(data.error); setStep('address'); setShowErrors(true); return; }
          setSendError(data.error || msg.startPayFail); return;
        }
        // Free gift code: no PaymentIntent — the server held the order for review and
        // returns { free:true }. Straight to the sent screen (same as covered).
        if (data.free) { setFreeApplied(true); if (data.orderId) setOrderId(data.orderId); setStep(sentStep); return; }
        // Covered card (prepaid credit): no PaymentIntent. A successful covered order
        // is already held server-side → straight to the sent screen. If the pool ran
        // out (covered:false), fall back to the pay flow: flip the request flag off,
        // flag the fallback for the UI, and re-create as a normal paid order.
        if (campaign && coverWantedRef.current) {
          if (data.covered) { if (data.orderId) setOrderId(data.orderId); setStep(sentStep); return; }
          if (data.reason === 'credits_exhausted' || data.covered === false) {
            coverWantedRef.current = false; setCoveredFellBack(true);
            setCreatingOrder(false); await createOrder(); return;
          }
        }
        setClientSecret(data.clientSecret);
        if (data.orderId) setOrderId(data.orderId);
        if (typeof data.amount === 'number') setPayAmount(data.amount);
        setPromoApplied(!!data.promoApplied);
      } catch (e) { setSendError(msg.network); }
      finally { setCreatingOrder(false); }
    }

    // Apply a promo code. First ask the server what KIND of code it is (read-only, no
    // commit): a FREE gift code ($0) switches the UI to the no-payment "place free card"
    // flow — no Payment Element, no charge; the order is held for review only when the
    // final CTA is clicked. Any other (paid) code falls through to the existing behavior:
    // re-open the PaymentIntent at the discounted price (the server enforces the price).
    async function applyPromo() {
      setPromoTried(true); setSendError('');
      const code = promo.trim();
      if (!code) return;
      let free = false;
      setCreatingOrder(true);
      try {
        const r = await fetch('/api/promo/check', {
          method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ code }),
        });
        const d = await r.json().catch(() => ({}));
        if (d && d.ok && d.free) {
          free = true;
          setClientSecret(''); setFreeApplied(true); setPromoApplied(true); setPayAmount(0);
          setFreeRemaining(typeof d.cardsRemaining === 'number' ? d.cardsRemaining : null);
        }
      } catch (e) { /* fall through to the paid path */ }
      setCreatingOrder(false);
      if (free) return;
      setFreeApplied(false); setFreeRemaining(null);
      setClientSecret(''); await createOrder();
    }
    function resetPayment() { setClientSecret(''); setSendError(''); }

    async function payNow() {
      if (!stripeRef.current) return;
      setPaying(true); setSendError('');
      const { stripe, elements } = stripeRef.current;
      const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: returnUrl }, redirect: 'if_required' });
      if (error) { setSendError(error.message || msg.payFail); setPaying(false); if (onPayError) onPayError(error.message); return; }
      setPaying(false); setStep(sentStep);   // webhook fulfills server-side
    }

    // No-Stripe fallback (dev / before keys are set): simulate via /api/postcard/send.
    // Campaign contributions have no no-Stripe path — the campaign branch lives only on
    // /api/order/create (which requires Stripe), and /api/postcard/send has no recipient
    // (the server fills the honoree), so it would fail confusingly. Block with a clear
    // message instead. In prod Stripe is always configured, so this never fires there.
    async function placeOrder() {
      if (campaign) { setSendError('Card contributions need payment enabled — this isn’t available in this preview.'); return; }
      setSendError(''); setPaying(true);
      try {
        const { image, preview, bouquet, previewBack } = await renderComposite();
        const res = await fetch('/api/postcard/send', {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(Object.assign({}, buildPayload(), { image, preview, bouquet, previewBack }, addrFields())),
        });
        const data = await res.json().catch(() => ({}));
        if (!res.ok) {
          if (res.status === 422 && data.field === 'to') { setSendError(data.error); setStep('address'); setShowErrors(true); return; }
          setSendError(data.error || msg.placeFail); return;
        }
        if (data.orderId) setOrderId(data.orderId);
        setStep(sentStep);
      } catch (e) { setSendError(msg.network); }
      finally { setPaying(false); }
    }

    // GIS sign-in callback: stash the ID token, then ask the server whether this
    // identity is an admin (we never trust the client to decide that). The token is
    // only ever sent to our own /api endpoints over the existing same-origin fetch.
    async function handleAdminCredential(credential) {
      if (!credential) return;
      adminTokenRef.current = credential;
      setAdminNote('');
      try {
        const r = await fetch('/api/admin/whoami', { headers: { Authorization: 'Bearer ' + credential } });
        const d = await r.json().catch(() => ({}));
        if (d && d.admin) { setAdminUser({ email: d.email, name: d.name || null }); setIsAdmin(true); }
        else { adminTokenRef.current = null; setAdminUser(null); setIsAdmin(false); setAdminNote('That account isn’t an admin.'); }
      } catch (e) { adminTokenRef.current = null; setIsAdmin(false); setAdminNote('Sign-in check failed — try again.'); }
    }
    function signOutAdmin() {
      adminTokenRef.current = null; setAdminUser(null); setIsAdmin(false); setAdminNote('');
      try { window.google && window.google.accounts && window.google.accounts.id.disableAutoSelect(); } catch (e) {}
    }

    // Admin free-send: fulfill without Stripe via the admin-gated endpoint. Mirrors
    // placeOrder, but carries the Bearer token and hits /api/order/admin-free.
    async function payFree() {
      if (!adminTokenRef.current) { setSendError('Please sign in again.'); return; }
      setSendError(''); setPaying(true);
      try {
        const { image, preview, bouquet, previewBack } = await renderComposite();
        const res = await fetch('/api/order/admin-free', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + adminTokenRef.current },
          body: JSON.stringify(Object.assign({}, buildPayload(), { image, preview, bouquet, previewBack }, addrFields())),
        });
        const data = await res.json().catch(() => ({}));
        if (!res.ok) {
          if (res.status === 422 && data.field === 'to') { setSendError(data.error); setStep('address'); setShowErrors(true); return; }
          setSendError(data.error || msg.placeFail); return;
        }
        if (data.orderId) setOrderId(data.orderId);
        setStep(sentStep);
      } catch (e) { setSendError(msg.network); }
      finally { setPaying(false); }
    }

    // Address step → review. Verify the recipient first so a bad address is caught
    // before payment (the server gates the charge on the same check).
    async function goReview() {
      if (!addrReady) { setShowErrors(true); return; }
      // Campaign mode: no recipient address to verify (the honoree's was verified at
      // campaign-create) — go straight to review.
      if (campaign) { setShowErrors(false); setSendError(''); setStep('review'); return; }
      setShowErrors(false); setSendError(''); setVerifying(true);
      try {
        for (let i = 0; i < tos.length; i++) {
          const res = await fetch('/api/address/verify', {
            method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cleanAddr(tos[i])),
          });
          const data = await res.json().catch(() => ({}));
          if (res.ok && data.checked && !data.deliverable) {
            const who = tos.length > 1 ? ` (${(tos[i].name || 'recipient ' + (i + 1)).trim()})` : '';
            setShowErrors(true); setSendError((data.message || msg.verifyFail) + who); return;
          }
        }
      } catch (e) { /* best-effort — proceed */ }
      finally { setVerifying(false); }
      setStep('review');
    }

    function goNext(designReady) {
      if (step === designStep && designReady) { setStep('address'); return; }
      if (step === 'address') { goReview(); return; }
      if (step === 'review') {
        // Every non-campaign path (paid, admin free-send, no-Stripe dev) needs a valid
        // tracking email — it's now collected on this step, so gate here. Campaign mode
        // supplies the email server-side from the signed-in identity.
        if (!campaign && !emailValid) { setShowErrors(true); return; }
        if (isAdminFree) { payFree(); return; }
        // Free gift code: no Payment Element — createOrder holds the order server-side
        // and returns { free:true } → sent. (Works on every site, even ones without the
        // bespoke free CTA: their "Continue to payment" button routes here too.)
        if (freeApplied) { createOrder(); return; }
        // Covered card (prepaid credit): submit the free covered order (no Payment
        // Element). createOrder handles the covered:true → sent, or the pool-exhausted
        // fallback to pay.
        if (campaign && coverWantedRef.current) { createOrder(); return; }
        // Two-phase paid: first click (no client secret yet) opens the PaymentIntent
        // with the confirmed email and reveals the Payment Element; the same CTA then
        // pays. Campaign mode skips this (it auto-creates on review entry).
        if (payEnabled && !campaign && !clientSecret) { createOrder(); return; }
        payEnabled ? payNow() : placeOrder(); return;
      }
    }
    function goBack() {
      if (step === 'address') setStep(designStep);
      else if (step === 'review') { resetPayment(); setStep('address'); }
    }
    // Reset checkout state; the site clears its own design state via resetDesign.
    function startOver(resetDesign) {
      if (resetDesign) resetDesign();
      setTo(emptyAddr()); setExtraTos([]); setUseOwnReturn(false);
      resetPayment(); setOrderId(''); setPromo(''); setPromoApplied(false); setPromoTried(false);
      setFreeApplied(false); setFreeRemaining(null); setPayAmount(price * 100);
      setCoveredFellBack(false); coverWantedRef.current = false;
      setStep(designStep);
    }

    return {
      step, setStep,
      to, setTo, ret, setRet, email, setEmail,
      // multi-recipient: tos[0] === to; sites without the UI can ignore all of these
      tos, setRecipient, addRecipient, removeRecipient, maxRecipients: MAX_RECIPIENTS,
      useOwnReturn, setUseOwnReturn,
      showErrors, setShowErrors, sendError, setSendError,
      errors, addrReady, emailValid,
      payEnabled, clientSecret, creatingOrder, paying, verifying,
      orderId, payAmount, promo, setPromo, promoApplied, promoTried, testMode,
      // free gift code ($0, no payment, held for review)
      freeApplied, freeRemaining,
      applyPromo, goNext, goBack, startOver,
      campaignMode: !!campaign,
      // Covered-card (prepaid credit) signals for the campaign UI.
      covered: campaignCovered && coverWantedRef.current, coveredFellBack,
      // admin free-send
      isAdminFree,
      admin: {
        clientId: googleClientId, user: adminUser, isAdmin, note: adminNote,
        freeMode, setFreeMode, onCredential: handleAdminCredential, signOut: signOutAdmin,
      },
    };
  }

  // Address autocomplete — POST a street prefix (+ optional city/state/zip context)
  // to our proxy, get back styled-by-the-site suggestions ({line1,city,state,zip}).
  // Best-effort: returns [] on any failure so it can never block typing. Each site
  // renders its own dropdown from these (we don't share the address markup).
  async function autocompleteAddress(prefix, ctx = {}) {
    if (!prefix || prefix.trim().length < 3) return [];
    try {
      const r = await fetch('/api/address/autocomplete', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prefix, city: ctx.city, state: ctx.state, zip: ctx.zip }),
      });
      const d = await r.json().catch(() => ({}));
      return Array.isArray(d.suggestions) ? d.suggestions : [];
    } catch (e) { return []; }
  }

  window.Checkout = {
    US_STATE, ZIP, EMAIL, emptyAddr, addrErrors, cleanAddr, isLight, wrapLines,
    loadArtImage, loadUrlImage, composeFront, composeBack, Field, StreetField, PasteAddress, parseAddress, useCheckout,
    AdminControls, autocompleteAddress,
  };
})();
