/* 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 6.25×9.25/300dpi 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
     • 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 6×9 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>
    );
  }

  // 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,
    } = opts;
    const msg = Object.assign({}, DEFAULT_MESSAGES, messages);

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

    // address state
    const [to, setTo] = useState(emptyAddr());
    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, setPromo] = useState('');
    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_');

    // ── 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); window.history.replaceState({}, '', window.location.pathname); }
    }, []);

    // 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.
    useEffect(() => {
      if (step === 'review' && payEnabled && !clientSecret && !creatingOrder && !isAdminFree) createOrder();
    }, [step, payEnabled, isAdminFree]);

    // 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]);

    // address validation
    const toErr = addrErrors(to);
    const retErr = addrErrors(ret);
    const emailValid = EMAIL.test(email.trim());
    const addrReady = Object.keys(toErr).length === 0 && emailValid && (!useOwnReturn || Object.keys(retErr).length === 0);
    const errors = showErrors ? { to: toErr, ret: retErr, email: emailValid ? null : 1 } : { to: {}, ret: {}, email: null };

    const addrFields = () => ({
      to: cleanAddr(to),
      ret: useOwnReturn ? cleanAddr(ret) : emptyAddr(),
      email: email.trim(),
    });

    async function createOrder() {
      setSendError(''); setCreatingOrder(true);
      try {
        const { image, preview, bouquet, previewBack } = await renderComposite();
        const res = await fetch('/api/order/create', {
          method: 'POST', 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;
        }
        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); }
    }

    // Re-open the PaymentIntent at the promo price (server enforces the discount).
    async function applyPromo() { setPromoTried(true); 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.
    async function placeOrder() {
      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; }
      setShowErrors(false); setSendError(''); setVerifying(true);
      try {
        const res = await fetch('/api/address/verify', {
          method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cleanAddr(to)),
        });
        const data = await res.json().catch(() => ({}));
        if (res.ok && data.checked && !data.deliverable) {
          setShowErrors(true); setSendError(data.message || msg.verifyFail); 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') {
        if (isAdminFree) { payFree(); 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()); setUseOwnReturn(false);
      resetPayment(); setOrderId(''); setPromo(''); setPromoApplied(false); setPromoTried(false);
      setStep(designStep);
    }

    return {
      step, setStep,
      to, setTo, ret, setRet, email, setEmail,
      useOwnReturn, setUseOwnReturn,
      showErrors, setShowErrors, sendError, setSendError,
      errors, addrReady,
      payEnabled, clientSecret, creatingOrder, paying, verifying,
      orderId, payAmount, promo, setPromo, promoApplied, promoTried, testMode,
      applyPromo, goNext, goBack, startOver,
      // 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, PasteAddress, parseAddress, useCheckout,
    AdminControls, autocompleteAddress,
  };
})();
