/* eslint-disable */
/* cityform — Commission confirm + Cart + Order (Brand v3) */

/* ────────────────────────────── AR Quick Look (iPhone, no app)
   USDZ is served at /api/ar/usdz/<id>.usdz. On an iPhone, the rel="ar"
   link launches AR Quick Look on tap. On desktop, the QR encodes the
   absolute USDZ URL — scan with the iPhone camera → Safari → Quick Look.
   (Requires the store to be reachable from the phone over HTTPS.) */
function ARQuickLook({ outputId }) {
  // A phone scanning the QR can't reach the Mac's localhost. Use the
  // server-configured public_base_url (LAN IP / HTTPS tunnel) when set;
  // fall back to the current origin (works when the page itself is
  // opened on a reachable host).
  const [base, setBase] = React.useState(window.location.origin);
  React.useEffect(() => {
    let live = true;
    fetch('/api/storefront/site')
      .then(r => r.ok ? r.json() : null)
      .then(d => { if (live && d && d.public_base_url) setBase(d.public_base_url); })
      .catch(() => {});
    return () => { live = false; };
  }, []);
  const arUrl = base + '/api/ar/usdz/' + outputId + '.usdz';
  const qrSrc = base + '/api/qr?data=' + encodeURIComponent(arUrl);
  return (
    <div style={{ marginTop:16, background:'var(--bone)', border:'1px solid var(--line)', padding:20,
      display:'flex', gap:20, alignItems:'center' }}>
      <div style={{ width:120, height:120, flexShrink:0, background:'var(--mist)',
        border:'1px solid var(--line)', display:'flex', alignItems:'center', justifyContent:'center', padding:8 }}>
        <img src={qrSrc} alt="AR QR" style={{ width:'100%', height:'100%' }}/>
      </div>
      <div style={{ flex:1 }}>
        <div className="label">See it in your room · iPhone</div>
        <p className="body" style={{ fontSize:13, color:'var(--stone)', marginTop:8, lineHeight:1.55 }}>
          Scan with your iPhone camera to place the model, at true 9&nbsp;cm scale,
          on any surface. No app needed.
        </p>
        <a rel="ar" href={arUrl} className="btn btn-ghost"
          style={{ marginTop:12, display:'inline-flex', height:36, fontSize:11 }}>
          <img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
            alt="" style={{ width:1, height:1 }}/>
          View in AR &nbsp;{Icon.arrow}
        </a>
      </div>
    </div>
  );
}

/* ────────────────────────────── STEP 3 · CONFIRM (live generation)
   Hybrid driver:
     · Timeline A (`stage`) advances on hardcoded setTimeouts to preserve the
       choreographed visual beats (scanline, water wash-in, building reveal).
     · Timeline B (`realProgress`) eases toward the backend's NDJSON
       progress.fraction via requestAnimationFrame so bursty network events
       don't make the SVG lurch.
   FinalPreview only swaps in once BOTH timelines have crossed the finish line.
*/
function StepConfirm({ chosen, bbox, exag, config, area, t, back, addToCart, go }) {
  const c = chosen || window.LOCATIONS[0];
  const STAGES = [
    { id:'init',  label:'Reproject WGS84 → BNG',                    ms: 600 },
    { id:'dtm',   label:'Fetch EA LIDAR · DTM 1 m',                  ms: 2400, side:'47.3 MB · gov.uk' },
    { id:'dsm',   label:'Fetch EA LIDAR · DSM (first return)',       ms: 2200, side:'52.8 MB · gov.uk' },
    { id:'osm',   label:'OSM · buildings, water, bridges',           ms: 1800, side:'overpass-turbo' },
    { id:'mesh',  label:'Build mesh · measured roof surfaces',       ms: 3200, side:'tier-3, water cutouts' },
    { id:'sticker', label:'Compose City Mark · ' + config.accent.toUpperCase(), ms: 1000 },
    { id:'render', label:'Render hero + wireframe',                  ms: 1600 },
    { id:'done',   label:'STL ready · sticker engraved',             ms: 600 },
  ];
  const [stage, setStage] = React.useState(-1);
  const [realProgress, setRealProgress] = React.useState(0);
  const [result, setResult] = React.useState(null);
  const [errorMsg, setErrorMsg] = React.useState(null);
  const [genTick, setGenTick] = React.useState(0); // bumping this re-runs the fetch
  const [commission, setCommission] = React.useState(null); // { ref, etsy_url, needs_manual }
  const targetRef = React.useRef(0);
  const resultRef = React.useRef(null);
  const abortRef = React.useRef(null);

  // Timeline A: choreographed stage labels. Stops once we're at the last stage.
  React.useEffect(() => {
    if (stage >= STAGES.length - 1) return;
    const handle = setTimeout(() => setStage(s => s + 1), Math.max(400, STAGES[Math.max(0, stage + 1)]?.ms || 1000));
    return () => clearTimeout(handle);
  }, [stage]);

  // Timeline B: rAF easing toward backend-reported fraction.
  React.useEffect(() => {
    let raf;
    const tick = () => {
      setRealProgress(p => {
        const target = targetRef.current;
        const delta = target - p;
        if (Math.abs(delta) < 0.0005) return p;
        // Max step ~0.012/frame baseline, scaling with how far behind we are.
        const step = Math.sign(delta) * Math.min(Math.abs(delta), 0.012 + Math.abs(delta) * 0.04);
        return p + step;
      });
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, []);

  // Fire /api/generate on mount (and on Regenerate). Stream NDJSON line-by-line.
  React.useEffect(() => {
    if (!chosen) return;
    // Coverage guard — never spend ~50 s on a generate the backend can't
    // fulfil. area comes from /api/storefront/area_preview (England check).
    if (area && area.in_england === false) {
      setErrorMsg(`${area.region || 'This location'} isn't available yet — commissions currently use Environment Agency LIDAR, which only covers England. Go back and pick a place in England.`);
      return;
    }
    const body = window.bboxToWGS84(chosen, bbox, { exag });
    body.dec = 1;          // full detail — store appearance prioritised (slower repair accepted)
    body.preview = false;  // clean, non-degraded, un-watermarked mesh (copy-protection deferred per product call)
    body.roof_detail = 'detailed';
    body.wall_style  = 'sloped';
    body.osm_3d      = false;
    body.osm_env_mode= 'promote';

    const ctrl = new AbortController();
    abortRef.current = ctrl;
    setStage(0);
    setRealProgress(0);
    targetRef.current = 0;
    setResult(null);
    resultRef.current = null;
    setErrorMsg(null);

    (async () => {
      try {
        const res = await fetch('/api/generate', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body),
          signal: ctrl.signal,
        });
        if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
        const reader = res.body.getReader();
        const decoder = new TextDecoder();
        let buf = '';
        while (true) {
          const { value, done } = await reader.read();
          if (done) break;
          buf += decoder.decode(value, { stream: true });
          let nl;
          while ((nl = buf.indexOf('\n')) >= 0) {
            const line = buf.slice(0, nl).trim();
            buf = buf.slice(nl + 1);
            if (!line) continue;
            let ev;
            try { ev = JSON.parse(line); } catch { continue; }
            if (ev.type === 'progress' && typeof ev.fraction === 'number') {
              targetRef.current = Math.max(targetRef.current, Math.min(1, ev.fraction));
            } else if (ev.type === 'done') {
              targetRef.current = 1;
              resultRef.current = ev;
              setResult(ev);
              setStage(STAGES.length - 1);
            } else if (ev.type === 'error') {
              setErrorMsg(ev.message || 'Generation failed.');
              return;
            }
          }
        }
      } catch (e) {
        if (e.name !== 'AbortError') setErrorMsg(e.message || 'Network error.');
      }
    })();

    return () => { ctrl.abort(); };
  }, [chosen, genTick]);

  const animationDone = stage >= STAGES.length - 1 && realProgress > 0.96 && !!result;

  const placeName = c.label.split(',')[0].split('·')[0].trim();
  // The single source of truth for this commission's coordinate: the
  // 1 km-square centre. Stored in the app-wide {lat,lng,ns,ew} catalog
  // shape (abs + hemisphere) so every consumer — sticker, cart, order —
  // renders the same value as Search / Adjust / the engraved label.
  const _cc = window.cropCentre(c, bbox);
  const cropCoords = {
    lat: Math.abs(_cc.lat), lng: Math.abs(_cc.lng),
    ns: _cc.lat >= 0 ? 'N' : 'S', ew: _cc.lng >= 0 ? 'E' : 'W',
  };
  const previewCity = {
    name: placeName,
    coords: cropCoords,
    accent: config.accent,
    landmarkId: config.landmarkId,
    landmark: config.landmarkLabel || 'Custom',
  };
  const tagLabel = {
    title: (config.labelTitle || placeName).toUpperCase(),
    subline: (config.labelSubline || '').toUpperCase(),
    coord: window.fmtCoord(_cc.lat, _cc.lng),
    showCoords: config.showCoords !== false,
  };

  const done = animationDone;
  const wgs84 = chosen ? window.bboxToWGS84(chosen, bbox, { exag }) : null;
  // The commission `done` event often omits output_id, but it's embedded
  // in stl_url (/output/<id>.stl?…) — that's the id the AR/USDZ route
  // resolves against.
  const arOid = result && (
    result.output_id ||
    (result.stl_url
      ? String(result.stl_url).split('/').pop().split('.')[0].split('?')[0]
      : '')
  );
  const fileSizeLabel = result?.file_mb != null ? `STL · ${result.file_mb.toFixed(1)} MB` : 'BUILDING…';
  const triLabel = result?.triangles != null ? result.triangles.toLocaleString() : '—';

  // Once the model is generated, persist the commission and get an order
  // reference + the Etsy listing to pay on. Fires once per output.
  React.useEffect(() => {
    if (!done || !arOid || commission) return;
    let live = true;
    fetch('/api/storefront/commission_request', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        output_id: arOid,
        place_name: placeName,
        coords: cropCoords,
        bbox: wgs84 && { lat_min: wgs84.lat_min, lat_max: wgs84.lat_max, lng_min: wgs84.lng_min, lng_max: wgs84.lng_max, angle_deg: wgs84.angle_deg },
        accent: config.accent,
        landmark: config.labelSubline || config.labelTitle || placeName,
        landmark_id: config.landmarkId,
        label: tagLabel,
        exag,
        triangles: result?.triangles,
        file_mb: result?.file_mb,
        stl_url: result?.stl_url,
      }),
    })
      .then(r => r.ok ? r.json() : Promise.reject(r.status))
      .then(d => { if (live) setCommission(d); })
      .catch(() => { if (live) setCommission({ ref: null, etsy_url: null, needs_manual: true }); });
    return () => { live = false; };
  }, [done, arOid]);

  // Build-log step progression is derived from realProgress (the backend
  // fraction) so the log and the % advance from the same clock. Previously
  // the log ran on a fixed ~13 s timer and ticked every step ✓ while
  // generation was genuinely still mid-way (e.g. 65%).
  const _stageEnds = (() => {
    const tot = STAGES.reduce((a, s) => a + s.ms, 0) || 1;
    let acc = 0;
    return STAGES.map(s => (acc += s.ms) / tot);
  })();
  const progStage = done
    ? STAGES.length - 1
    : _stageEnds.reduce((n, f) => n + (realProgress >= f ? 1 : 0), 0) - 1;

  const regenerate = () => {
    abortRef.current?.abort();
    setGenTick(n => n + 1);
  };

  return (
    <div className="container" style={{ paddingTop:40, paddingBottom:64 }}>
      <div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', marginBottom:32 }}>
        <div>
          <div className="label">Step 04 of 04 · Confirm</div>
          <h2 className="h1" style={{ fontSize:56, marginTop:14 }}>
            {errorMsg ? 'We hit a snag.' : (done ? 'Your commission is ready.' : 'Generating your model.')}
          </h2>
          <p className="body" style={{ marginTop:14, fontSize:16, color:'var(--stone)', maxWidth:580 }}>
            {errorMsg
              ? errorMsg
              : (done
                ? 'A free preview, no payment required. Drag the model to inspect it, then place the order — we ship in eight weeks.'
                : 'Fetching real EA LIDAR + OpenStreetMap data and composing the City Mark. About thirty seconds.')}
          </p>
        </div>
        {!done && !errorMsg && (
          <div style={{ display:'flex', alignItems:'center', gap:14 }}>
            <div style={{ width:36, height:36, border:'2px solid var(--line)', borderTopColor:'var(--ink)', animation:'cf-spin 1s linear infinite' }}/>
            <span style={{ fontSize:11, letterSpacing:'0.18em', fontWeight:500, fontVariantNumeric:'tabular-nums' }}>{Math.round(realProgress * 100)}%</span>
          </div>
        )}
        {done && !errorMsg && (
          <button className="btn-link" onClick={regenerate}>
            ↺ Regenerate
          </button>
        )}
        {errorMsg && (
          <button className="btn-link" onClick={regenerate}>
            ↺ Try again
          </button>
        )}
      </div>

      <div style={{ display:'grid', gridTemplateColumns:'1.4fr 1fr', gap:48 }}>
        {/* PREVIEW */}
        <div>
          <div style={{
            position:'relative',
            border:'1px solid var(--ink)', overflow:'hidden',
            background:'var(--mist)',
            aspectRatio:'4/3', display:'flex', alignItems:'center', justifyContent:'center',
            filter: errorMsg ? 'grayscale(0.6) sepia(0.4) hue-rotate(-30deg) saturate(1.4)' : 'none',
            transition: 'filter 0.4s ease',
          }}>
            {!done && <GenerationWireframe seed={c.q + '-gen'} progress={realProgress}
              area={area && area.in_england ? area : null} bbox={bbox}/>}
            {done && <FinalPreview seed={c.q + '-final'} t={t} city={previewCity} stlUrl={result?.stl_url} label={tagLabel}/>}
            <div style={{ position:'absolute', top:14, left:14 }} className="label">{window.fmtCoord(_cc.lat, _cc.lng)}</div>
            <div style={{ position:'absolute', top:14, right:14 }} className="label">{((bbox.w / 100) * 2.5).toFixed(2)} × {((bbox.w / 100) * 2.5).toFixed(2)} KM</div>
            <div style={{ position:'absolute', bottom:14, left:14 }} className="label">1:11000</div>
            <div style={{ position:'absolute', bottom:14, right:14 }} className="label">{errorMsg ? 'ERROR' : (done ? fileSizeLabel : 'BUILDING…')}</div>
          </div>

          {done && (
            <div style={{ marginTop:16, display:'grid', gridTemplateColumns:'1fr 1fr', gap:16 }}>
              <div style={{ background:'var(--bone)', border:'1px solid var(--line)', padding:20 }}>
                <div className="label" style={{ marginBottom:14 }}>Top-down render</div>
                <div style={{ aspectRatio:'1/1', background:'var(--mist)', border:'1px solid var(--line)', position:'relative', overflow:'hidden' }}>
                  {result?.stl_url
                    ? <STLViewer url={result.stl_url} topDown/>
                    : <StyledMap seed={c.q + '-top'}/>}
                </div>
              </div>
              <div style={{ background:'var(--bone)', border:'1px solid var(--line)', padding:20 }}>
                <div className="label" style={{ marginBottom:14 }}>City Mark · 80 mm</div>
                <div style={{ aspectRatio:'1/1', display:'flex', alignItems:'center', justifyContent:'center' }}>
                  <CitySticker city={previewCity} size={220}/>
                </div>
              </div>
            </div>
          )}
          {done && arOid && <ARQuickLook outputId={arOid}/>}
        </div>

        {/* Right: build log + summary */}
        <div>
          <div style={{ background:'var(--bone)', border:'1px solid var(--line)' }}>
            <div style={{ padding:'14px 20px', borderBottom:'1px solid var(--line)', display:'flex', justifyContent:'space-between', alignItems:'center' }}>
              <div className="label">Build log</div>
              {!done && <div style={{ fontSize:9, color:'var(--vermilion)', letterSpacing:'0.22em', fontWeight:500 }}>● LIVE</div>}
              {done && <div style={{ fontSize:9, color:'var(--jade)', letterSpacing:'0.22em', fontWeight:500 }}>● COMPLETE</div>}
            </div>
            <div style={{ padding:'14px 20px', fontSize:12 }}>
              {STAGES.map((s, i) => {
                const status = (done || i <= progStage)
                  ? 'done'
                  : (i === progStage + 1 ? 'pending' : 'queued');
                return (
                  <div key={s.id} style={{ display:'flex', alignItems:'flex-start', gap:10, padding:'8px 0',
                    borderBottom: i < STAGES.length - 1 ? '1px dotted var(--line-strong)' : 'none',
                    opacity: status === 'queued' ? 0.4 : 1,
                  }}>
                    <span style={{ width:14, paddingTop:4 }}>
                      {status === 'done' && <span style={{ color:'var(--ink)' }}>✓</span>}
                      {status === 'pending' && <span style={{ display:'inline-block', width:9, height:9, border:'1.5px solid var(--ink)', borderTopColor:'transparent', animation:'cf-spin 0.9s linear infinite' }}/>}
                      {status === 'queued' && <span style={{ color:'var(--stone)' }}>·</span>}
                    </span>
                    <div style={{ flex:1, fontVariantNumeric:'tabular-nums' }}>
                      <div style={{ color: status === 'queued' ? 'var(--stone)' : 'var(--ink)' }}>{s.label}</div>
                      {s.side && <div style={{ color:'var(--stone)', fontSize:10, marginTop:2 }}>{s.side}</div>}
                    </div>
                    <span style={{ color:'var(--stone)', fontSize:10 }}>
                      {status === 'done' ? `${(s.ms/1000).toFixed(1)}s` : ''}
                    </span>
                  </div>
                );
              })}
            </div>
          </div>

          {/* Summary */}
          <div style={{ marginTop:20, background:'var(--bone)', border:'1px solid var(--ink)', padding:'20px 24px' }}>
            <div className="label">Your commission</div>
            <div className="h1" style={{ fontSize:36, marginTop:6 }}>{placeName}</div>
            <div className="caption" style={{ marginTop:6, fontSize:11 }}>
              {window.fmtCoord(_cc.lat, _cc.lng)}
            </div>

            <div style={{ marginTop:18, borderTop:'1px dotted var(--line-strong)' }}>
              {[
                ['Format',   '9 × 9 × 1.6 cm · 1:11000'],
                ['Material', 'PETG · Mist · hand-finished'],
                ['Signature', <span style={{ display:'inline-flex', alignItems:'center', gap:8 }}>
                  <span style={{ width:10, height:10, background:`var(--${config.accent})` }}/>
                  {window.ACCENTS[config.accent].name}
                </span>],
                ['Landmark', config.landmarkLabel || 'Custom'],
                ['Rotation', `${bbox.r}°`],
                ['Lead time', 'Eight weeks'],
              ].map(([k, v]) => (
                <div key={k} style={{ display:'grid', gridTemplateColumns:'120px 1fr', gap:18, padding:'10px 0', borderBottom:'1px dotted var(--line-strong)' }}>
                  <span className="label">{k}</span>
                  <span style={{ fontSize:13 }}>{v}</span>
                </div>
              ))}
            </div>

            <div style={{ marginTop:18, paddingTop:14, borderTop:'1px solid var(--ink)',
              display:'flex', justifyContent:'space-between', alignItems:'baseline' }}>
              <div className="label">Commission</div>
              <div style={{ textAlign:'right' }}>
                <div className="h1" style={{ fontSize:32 }}>£240</div>
                <div className="caption" style={{ fontSize:10, marginTop:2 }}>paid securely on Etsy</div>
              </div>
            </div>

            {(() => {
              const toCartAndOrder = () => {
                addToCart({
                  id: 'custom-' + (arOid || Date.now()),
                  name: placeName,
                  region: 'Custom commission',
                  type: 'Bespoke',
                  accent: config.accent,
                  landmarkId: config.landmarkId,
                  landmark: config.labelSubline || config.labelTitle || 'Custom',
                  coords: cropCoords,
                  bbox: wgs84 && { lat_min: wgs84.lat_min, lat_max: wgs84.lat_max, lng_min: wgs84.lng_min, lng_max: wgs84.lng_max, angle_deg: wgs84.angle_deg },
                  stl_url: result?.stl_url,
                  triangles: result?.triangles,
                  file_mb: result?.file_mb,
                  photo: 'assets/products/whitby_hero.jpg',
                  price: 240,
                  custom: true,
                  commission_ref: commission?.ref || null,
                  etsy_url: commission?.etsy_url || null,
                });
                go('order');
              };
              if (!done) return <button className="btn btn-primary" disabled style={{ width:'100%', marginTop:18 }}>Generating…</button>;
              if (!commission) return <button className="btn btn-primary" disabled style={{ width:'100%', marginTop:18 }}>Preparing your order…</button>;
              return (
                <div style={{ marginTop:18 }}>
                  {commission.ref && (
                    <div style={{ background:'var(--bone)', border:'1px solid var(--ink)', padding:'14px 16px' }}>
                      <div className="label" style={{ fontSize:9 }}>Your order reference</div>
                      <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10, marginTop:6 }}>
                        <div className="h3" style={{ fontSize:20, fontWeight:600, fontVariantNumeric:'tabular-nums', letterSpacing:'0.04em' }}>{commission.ref}</div>
                        <button className="btn-link" style={{ fontSize:11 }}
                          onClick={() => { try { navigator.clipboard.writeText(commission.ref); } catch (e) {} }}>Copy</button>
                      </div>
                      <p className="body" style={{ fontSize:11.5, color:'var(--stone)', marginTop:8, lineHeight:1.5 }}>
                        Paste this into Etsy's <strong>“Note to seller”</strong> at checkout so we match your exact design.
                      </p>
                    </div>
                  )}
                  {commission.etsy_url ? (
                    <button className="btn btn-primary" style={{ width:'100%', marginTop:14 }}
                      onClick={() => { window.open(commission.etsy_url, '_blank', 'noopener'); toCartAndOrder(); }}>
                      Complete your order on Etsy &nbsp;{Icon.arrow}
                    </button>
                  ) : (
                    <a className="btn btn-primary" style={{ width:'100%', marginTop:14, display:'flex' }}
                      href={`mailto:studio@cityform.co?subject=${encodeURIComponent('Cityform commission ' + (commission.ref || ''))}&body=${encodeURIComponent('I would like to order commission ' + (commission.ref || '') + ' (' + placeName + ').')}`}
                      onClick={toCartAndOrder}>
                      Email the studio to order &nbsp;{Icon.arrow}
                    </a>
                  )}
                  <div className="caption" style={{ fontSize:10, marginTop:10, textAlign:'center' }}>
                    Payment, shipping &amp; returns are handled by Etsy. Production starts once Etsy confirms payment.
                  </div>
                </div>
              );
            })()}
            <button className="btn-link" style={{ marginTop:14, display:'block', width:'100%', textAlign:'center', fontSize:12, color:'var(--stone)' }} onClick={back}>
              ← Adjust label
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ────────────────────────────── Generation wireframe
   Reveals the customer's ACTUAL commissioned crop as it generates: same
   real OSM roads/water/buildings used by the picker, projected via
   window.projectArea, cropped+rotated to the selected bbox so it's their
   model building up — not a generic city. Falls back to the procedural
   tile when no real area is available (offline / pre-fetch). The reveal
   choreography, crosshair, scanline and fog are byte-for-byte unchanged. */
function GenerationWireframe({ seed, progress, area, bbox }) {
  const tile = React.useMemo(() => generateTile(seed), [seed]);
  const real = React.useMemo(
    () => (area && window.projectArea ? window.projectArea(area) : null),
    [area]
  );
  const useReal = !!(real && (real.roads.length || real.buildings.length || real.water.length));

  // Crop transform: map the selected bbox sub-rect to the full 1000×1000
  // frame, rotated upright. Strokes use non-scaling-stroke so hairline
  // weight stays constant regardless of the zoom factor.
  let cropTransform;
  if (useReal && bbox) {
    const cx = (bbox.x + bbox.w / 2) * 10;
    const cy = (bbox.y + bbox.h / 2) * 10;
    const s = 100 / Math.max(4, Math.min(bbox.w, bbox.h));
    cropTransform = `translate(500 500) rotate(${-(bbox.r || 0)}) scale(${s}) translate(${-cx} ${-cy})`;
  }

  const roads = useReal ? real.roads : tile.roads;
  const builds = useReal ? real.buildings : tile.buildings;
  const visibleRoads = Math.floor(roads.length * Math.min(1, progress * 1.6));
  const visibleBuildings = Math.floor(builds.length * Math.min(1, Math.max(0, progress - 0.2) * 1.6));
  const waterOpacity = Math.min(1, (progress - 0.3) * 2);

  return (
    <svg viewBox="0 0 1000 1000" style={{ width:'80%', height:'80%' }}>
      <defs>
        <linearGradient id="genFog" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="var(--mist)" stopOpacity="0"/>
          <stop offset="100%" stopColor="var(--mist)" stopOpacity="0.65"/>
        </linearGradient>
        <clipPath id="genClip"><rect x="0" y="0" width="1000" height="1000"/></clipPath>
      </defs>
      {/* Crosshair — screen space, unchanged */}
      <g opacity={1 - progress * 0.7}>
        <line x1="0" y1="500" x2="1000" y2="500" stroke="var(--ink)" strokeWidth="1" strokeDasharray="6 6"/>
        <line x1="500" y1="0" x2="500" y2="1000" stroke="var(--ink)" strokeWidth="1" strokeDasharray="6 6"/>
        <circle cx="500" cy="500" r="180" fill="none" stroke="var(--ink)" strokeWidth="0.6" strokeDasharray="4 6"/>
      </g>

      <g clipPath="url(#genClip)">
        <g transform={cropTransform}>
          {useReal ? (
            <>
              {progress > 0.35 && real.water.map((pts, i) => (
                <polygon key={'w'+i} points={pts} fill="#9BA09B" stroke="none"
                  opacity={waterOpacity * 0.5}/>
              ))}
              <g stroke="var(--ink)" fill="none" strokeLinecap="round">
                {real.roads.slice(0, visibleRoads).map((r, i) => (
                  <path key={i} d={r.d} strokeWidth={Math.max(0.8, r.w * 0.6)}
                    vectorEffect="non-scaling-stroke" style={{ opacity: 0.9 }}/>
                ))}
              </g>
              <g fill="none" stroke="var(--ink)" strokeWidth="1.2">
                {real.buildings.slice(0, visibleBuildings).map((pts, i) => (
                  <polygon key={i} points={pts} vectorEffect="non-scaling-stroke"/>
                ))}
              </g>
            </>
          ) : (
            <>
              {tile.waterPath && progress > 0.35 && (
                <path d={tile.waterPath} fill="none" stroke="#9BA09B" strokeWidth="48" opacity={waterOpacity}/>
              )}
              <g stroke="var(--ink)" fill="none" strokeLinecap="round">
                {tile.roads.slice(0, visibleRoads).map((r, i) => (
                  <path key={i} d={r.d} strokeWidth={r.w * 0.6} style={{ opacity: 0.9 }}/>
                ))}
              </g>
              <g fill="none" stroke="var(--ink)" strokeWidth="1.2">
                {tile.buildings.slice(0, visibleBuildings).map((b, i) => (
                  <rect key={i} x={b.x} y={b.y} width={b.w} height={b.h}
                    transform={b.r ? `rotate(${b.r} ${b.x + b.w/2} ${b.y + b.h/2})` : undefined}/>
                ))}
              </g>
            </>
          )}
        </g>
      </g>

      {/* Scanline + fog — screen space, unchanged */}
      <line x1="0" y1={1000 * Math.min(1, progress * 1.05)} x2="1000" y2={1000 * Math.min(1, progress * 1.05)}
        stroke="var(--ink)" strokeWidth="1.5" opacity="0.5"/>
      <rect x="0" y={1000 * Math.min(1, progress * 1.05)} width="1000" height="1000" fill="url(#genFog)" opacity="0.7"/>
    </svg>
  );
}

/* ────────────────────────────── Live STL viewer (Three.js)
   Renders the actual generated city mesh. Geometry is fetched + parsed once
   per URL and cached at module scope so the hero viewer and the small
   top-down panel share a single ~8 MB download/parse. STLs are Z-up
   (heights on Z), matching the admin's stl_viewer.js convention. */
const _stlGeomCache = new Map(); // url -> Promise<THREE.BufferGeometry>

function loadStlGeometry(url) {
  if (_stlGeomCache.has(url)) return _stlGeomCache.get(url);
  const p = new Promise((resolve, reject) => {
    if (!window.THREE || !THREE.STLLoader) { reject(new Error('three.js not loaded')); return; }
    new THREE.STLLoader().load(url, (geo) => {
      geo.computeVertexNormals();
      geo.center();
      resolve(geo);
    }, undefined, reject);
  });
  _stlGeomCache.set(url, p);
  return p;
}

function STLViewer({ url, topDown = false, accent, label }) {
  const mountRef = React.useRef(null);
  const [phase, setPhase] = React.useState('loading'); // loading | ready | error

  React.useEffect(() => {
    if (!url) return;
    let disposed = false;
    let teardown = null;

    // v1 HTML loads three.js with blocking <script> tags (window.THREE is
    // present synchronously). v2 HTML loads it via an async probe exposed as
    // window.__threeLoad — await it before deciding the viewer can't render,
    // otherwise the base+tag preview silently shows "PREVIEW UNAVAILABLE".
    const start = () => {
      if (disposed) return;
      const mount = mountRef.current;
      if (!mount || !window.THREE) { setPhase('error'); return; }

      let renderer, scene, camera, controls, mesh, base, holder, tag, raf, ro;

    const w = mount.clientWidth || 480;
    const h = mount.clientHeight || 360;

    scene = new THREE.Scene();
    scene.background = new THREE.Color(0xE1E5E0); // --mist, blends with the frame
    scene.up.set(0, 0, 1);

    camera = new THREE.PerspectiveCamera(42, w / h, 0.1, 50000);
    camera.up.set(0, 0, 1);

    renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setPixelRatio(window.devicePixelRatio || 1);
    renderer.setSize(w, h);
    mount.appendChild(renderer.domElement);

    // Soft, even lighting for a matte 3D-printed read: a hemisphere fill
    // (sky → ground bounce, like a lightbox) plus one gentle key from
    // above. Avoids the hard hot-spots that made the mesh look "wet".
    const hemi = new THREE.HemisphereLight(0xffffff, 0xB8BDB7, 0.5);
    hemi.position.set(0, 0, 1);
    scene.add(hemi);
    scene.add(new THREE.AmbientLight(0xffffff, 0.08));
    const key = new THREE.DirectionalLight(0xffffff, 0.55);  key.position.set(0.4, -0.9, 1.4); scene.add(key);

    if (!topDown) {
      controls = new THREE.OrbitControls(camera, renderer.domElement);
      controls.enableDamping = true;
      controls.dampingFactor = 0.08;
      controls.enablePan = false;
      controls.autoRotate = true;
      controls.autoRotateSpeed = 0.9;
    }

    loadStlGeometry(url).then((geo) => {
      if (disposed) return;
      const material = new THREE.MeshStandardMaterial({
        color: 0xD7D9D3, metalness: 0.0, roughness: 0.95,
        flatShading: true, side: THREE.DoubleSide,
      });
      // Subtle FDM layer lines — faint horizontal banding along the build
      // (Z) axis so the model reads as a 3D print, not a smooth CAD render.
      material.onBeforeCompile = (shader) => {
        shader.vertexShader = shader.vertexShader
          .replace('#include <common>', '#include <common>\nvarying vec3 vCfWPos;')
          .replace('#include <begin_vertex>', '#include <begin_vertex>\nvCfWPos = (modelMatrix * vec4(position, 1.0)).xyz;');
        shader.fragmentShader = shader.fragmentShader
          .replace('#include <common>', '#include <common>\nvarying vec3 vCfWPos;')
          .replace('#include <dithering_fragment>', '#include <dithering_fragment>\nfloat cfL = 0.5 + 0.5 * sin(vCfWPos.z * 3.0);\ngl_FragColor.rgb *= 1.0 - 0.045 * cfL;');
      };
      mesh = new THREE.Mesh(geo, material);
      scene.add(mesh);

      // Real presentation base STL — assets/accessories/9x9 base (base only).stl,
      // served read-only via /api/accessory. It's authored Y-up at a
      // 104.6 mm footprint / 16.2 mm thickness; the city mesh is Z-up at
      // the 89.5 mm product scale. Both are real mm, so we use NATIVE
      // scale (the 104.6/89.5 size relationship is the true product) —
      // only axis-fix, recentre, and Z-seat. Guard: if the model isn't at
      // product scale, scale the base preserving that ratio. If the STL
      // can't load, fall back to a plain slab so the preview never breaks.
      const mb = new THREE.Box3().setFromObject(mesh);
      const sz = mb.getSize(new THREE.Vector3());
      const footW = sz.x || 100, footD = sz.y || 100;
      const modelFoot = Math.max(footW, footD);

      const frameTo = (objs) => {
        const bb = new THREE.Box3();
        objs.forEach(o => bb.expandByObject(o));
        const center = bb.getCenter(new THREE.Vector3());
        const sphere = bb.getBoundingSphere(new THREE.Sphere());
        const r = sphere.radius > 0 ? sphere.radius : 100;
        const fov = THREE.MathUtils.degToRad(camera.fov);
        const distance = (r / Math.sin(fov / 2)) * (topDown ? 1.05 : 1.15);
        const dir = topDown
          ? new THREE.Vector3(0, 0, 1)
          : new THREE.Vector3(0.5, -0.78, 0.55).normalize();
        camera.position.copy(center.clone().add(dir.multiplyScalar(distance)));
        camera.lookAt(center);
        if (controls) { controls.target.copy(center); controls.update(); }
      };

      const baseMat   = new THREE.MeshStandardMaterial({ color: 0x2C313A, metalness: 0.0, roughness: 0.6 });
      const holderMat = new THREE.MeshStandardMaterial({ color: 0xF5F6F3, metalness: 0.0, roughness: 0.7 });
      const tagMat    = new THREE.MeshStandardMaterial({ color: 0xC9CDD1, metalness: 0.85, roughness: 0.35 });

      // The base, tag holder and tag all share the accessory CAD origin.
      // We derive ONE fit (axis-flip + scale + seat) from the base STL and
      // reuse it verbatim for the holder and tag, so the assembly stays
      // bolted together exactly as authored.
      let fit = null;   // { s, tx, ty, tz }
      const applyFit = (g) => {
        g.rotateX(Math.PI / 2);
        g.scale(fit.s, fit.s, fit.s);
        g.translate(fit.tx, fit.ty, fit.tz);
        return g;
      };
      const computeFitFromBase = (bg) => {
        bg.rotateX(Math.PI / 2);        // Y-up → Z-up (thickness lands on Z)
        bg.computeBoundingBox();
        let bb = bg.boundingBox;
        const bFootX = bb.max.x - bb.min.x;
        let s = 1;
        if (Math.abs(modelFoot - 89.5) > 89.5 * 0.5 && bFootX > 0) {
          s = (modelFoot * (104.6 / 89.5)) / bFootX;   // preserve real ratio
          bg.scale(s, s, s);
          bg.computeBoundingBox();
          bb = bg.boundingBox;
        }
        const cx = (bb.min.x + bb.max.x) / 2;
        const cy = (bb.min.y + bb.max.y) / 2;
        const thick = bb.max.z - bb.min.z;
        fit = { s, tx: -cx, ty: -cy, tz: mb.min.z - bb.max.z + thick * 0.02 };
        bg.translate(fit.tx, fit.ty, fit.tz);
        return bg;
      };

      const syntheticBaseMesh = () => {
        const t = modelFoot * 0.12;
        const m = new THREE.Mesh(new THREE.BoxGeometry(footW * 1.04, footD * 1.04, t), baseMat);
        m.position.set(0, 0, mb.min.z - t / 2 + t * 0.02);
        return m;
      };

      // Engraved-label CanvasTexture — reproduces the laser-label artwork
      // (Design System v1.1: carbon plate #2C313A, pearl #C8CCD0 text +
      // divider, city-plate vs area-plate layout). Same content the SVG
      // preview in the Label step shows.
      const makeLabelTexture = (lab) => {
        const S = 24;                       // px per mm (uniform)
        const cv = document.createElement('canvas');
        cv.width = Math.round(57.6 * S); cv.height = Math.round(7.6 * S);
        const x = cv.getContext('2d');
        const mmX = v => v * S, mmY = v => v * S;
        x.fillStyle = '#2C313A'; x.fillRect(0, 0, cv.width, cv.height);
        const pearl = '#C8CCD0';
        // divider tick (§3)
        x.strokeStyle = pearl; x.lineWidth = mmX(0.25);
        x.beginPath(); x.moveTo(mmX(33.6), mmY(1.9)); x.lineTo(mmX(33.6), mmY(5.7)); x.stroke();
        x.fillStyle = pearl; x.textBaseline = 'alphabetic';
        const FF = "'Inter','Helvetica Neue',Arial,sans-serif";
        const set = (w, sz, ls) => { x.font = `${w} ${mmY(sz)}px ${FF}`; x.letterSpacing = `${mmX(ls)}px`; };
        if (lab.subline) {                  // area plate (§4/§6)
          x.textAlign = 'left';
          set(600, 1.55, 0.45); x.fillText(lab.title,   mmX(3), mmY(2.91));
          set(600, 2.0,  0.18); x.fillText(lab.subline, mmX(3), mmY(5.81));
        } else {                            // city plate
          x.textAlign = 'left';
          set(600, 2.4, 0.28); x.fillText(lab.title, mmX(3), mmY(4.70));
        }
        if (lab.showCoords) {
          x.textAlign = 'right';
          set(400, 1.8, 0.16); x.fillText(lab.coord, mmX(54.6), mmY(4.46));
        }
        const tex = new THREE.CanvasTexture(cv);
        if ('SRGBColorSpace' in THREE) tex.colorSpace = THREE.SRGBColorSpace;
        else if (THREE.sRGBEncoding) tex.encoding = THREE.sRGBEncoding;
        tex.needsUpdate = true;
        return tex;
      };

      // Tag — a thin pill (57.6 × 7.6 mm laser-label outline) placed in
      // the holder slot via the measured bounds + shared fit. Its faces
      // carry the engraving texture when a label is supplied.
      const buildTagMesh = () => {
        const L = 57.6, H = 7.6, TH = 0.8, R = 3.8;
        const sh = new THREE.Shape();
        sh.moveTo(-L / 2 + R, -H / 2);
        sh.lineTo(L / 2 - R, -H / 2);
        sh.absarc(L / 2 - R, 0, R, -Math.PI / 2, Math.PI / 2, false);
        sh.lineTo(-L / 2 + R, H / 2);
        sh.absarc(-L / 2 + R, 0, R, Math.PI / 2, Math.PI * 1.5, false);
        // UVs in [0,1] over the shape bbox so the label maps 1:1 onto the
        // flat faces (default extrude UVs use raw shape coords).
        const uvGen = {
          generateTopUV(g, vs, a, b, cc) {
            const f = i => new THREE.Vector2((vs[i * 3] + L / 2) / L, (vs[i * 3 + 1] + H / 2) / H);
            return [f(a), f(b), f(cc)];
          },
          generateSideWallUV() {
            const z = new THREE.Vector2(0, 0); return [z, z, z, z];
          },
        };
        const tg = new THREE.ExtrudeGeometry(sh, { depth: TH, bevelEnabled: false, UVGenerator: uvGen });
        tg.rotateY(-Math.PI / 2);   // length→Z, height→Y, thickness→X (CAD frame)
        // The holder slot is NOT axis-aligned: its walls lean 14.036°
        // about Z (measured from the STL — slot-wall normals
        // (±0.970, ∓0.243, 0), a ~1.04 mm gap). Match that lean so the
        // plate sits parallel to the slot instead of skewed through it.
        tg.rotateZ(-0.24495);
        tg.computeBoundingBox();
        const c = tg.boundingBox.getCenter(new THREE.Vector3());
        // Seat the plate centred on the measured slot gap-centre with no
        // upward bias, so it sits down IN the holder rather than floating
        // above it. (slot-wall centroid; gap mid-point.)
        const TX = -4.910, TY = -3.652, TZ = -44.684;
        tg.translate(TX - c.x, TY - c.y, TZ - c.z);
        applyFit(tg);
        const mat = label
          ? new THREE.MeshStandardMaterial({ map: makeLabelTexture(label), color: 0xffffff, metalness: 0.0, roughness: 0.5 })
          : tagMat;
        return new THREE.Mesh(tg, mat);
      };

      const finalize = () => {
        if (disposed) return;
        frameTo([mesh, base, holder, tag].filter(Boolean));
      };

      // Tag holder STL + tag — only meaningful with the real base (shared
      // CAD origin / fit). Skipped on the synthetic-slab fallback.
      const attachHolderAndTag = () => {
        if (disposed || !fit) return;
        const holderUrl = '/api/accessory/' + encodeURIComponent('9x9 base (location tag holder).stl');
        const addTag = () => { if (!disposed) { tag = buildTagMesh(); scene.add(tag); finalize(); } };
        if (window.THREE && THREE.STLLoader) {
          new THREE.STLLoader().load(
            holderUrl,
            (hg) => {
              if (disposed) return;
              hg.computeVertexNormals();
              applyFit(hg);
              holder = new THREE.Mesh(hg, holderMat);
              scene.add(holder);
              addTag();
            },
            undefined,
            addTag,   // holder STL failed — still show the tag in place
          );
        } else {
          addTag();
        }
      };

      const attachBase = (m, isReal) => {
        if (disposed) return;
        base = m;
        scene.add(base);
        frameTo([mesh, base]);
        if (isReal) attachHolderAndTag();
      };

      frameTo([mesh]);     // frame the model immediately; parts pop in async
      setPhase('ready');

      const baseUrl = '/api/accessory/' + encodeURIComponent('9x9 base (base only).stl');
      if (window.THREE && THREE.STLLoader) {
        new THREE.STLLoader().load(
          baseUrl,
          (bg) => { bg.computeVertexNormals(); attachBase(new THREE.Mesh(computeFitFromBase(bg), baseMat), true); },
          undefined,
          () => attachBase(syntheticBaseMesh(), false),
        );
      } else {
        attachBase(syntheticBaseMesh(), false);
      }
    }).catch(() => { if (!disposed) setPhase('error'); });

    const animate = () => {
      raf = requestAnimationFrame(animate);
      if (controls) controls.update();
      renderer.render(scene, camera);
    };
    animate();

    ro = new ResizeObserver(() => {
      if (disposed) return;
      const nw = mount.clientWidth, nh = mount.clientHeight;
      if (!nw || !nh) return;
      camera.aspect = nw / nh;
      camera.updateProjectionMatrix();
      renderer.setSize(nw, nh);
    });
    ro.observe(mount);

      teardown = () => {
        disposed = true;
        cancelAnimationFrame(raf);
        if (ro) ro.disconnect();
        if (controls) controls.dispose();
        if (mesh) mesh.material.dispose();   // geometry stays cached for reuse
        if (base) { base.geometry.dispose(); base.material.dispose(); }
        if (holder) { holder.geometry.dispose(); holder.material.dispose(); }
        if (tag) { tag.geometry.dispose(); if (tag.material.map) tag.material.map.dispose(); tag.material.dispose(); }
        renderer.dispose();
        if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
      };
    };

    if (window.THREE) {
      start();
    } else if (window.__threeLoad && typeof window.__threeLoad.then === 'function') {
      window.__threeLoad.then(start, start);
    } else {
      setPhase('error');
    }

    return () => { disposed = true; if (teardown) teardown(); };
  }, [url, topDown]);

  return (
    <div style={{ position:'absolute', inset:0 }}>
      <div ref={mountRef} style={{ width:'100%', height:'100%' }}/>
      {phase === 'loading' && (
        <div style={{ position:'absolute', inset:0, display:'flex', alignItems:'center', justifyContent:'center',
          pointerEvents:'none' }}>
          <span className="label" style={{ color:'var(--stone)', fontSize:9 }}>LOADING MESH…</span>
        </div>
      )}
      {phase === 'error' && (
        <div style={{ position:'absolute', inset:0, display:'flex', alignItems:'center', justifyContent:'center' }}>
          <span className="label" style={{ color:'var(--stone)', fontSize:9 }}>PREVIEW UNAVAILABLE</span>
        </div>
      )}
      {!topDown && phase === 'ready' && (
        <div style={{ position:'absolute', bottom:10, left:0, right:0, textAlign:'center', pointerEvents:'none' }}>
          <span className="label" style={{ fontSize:8, color:'var(--stone)' }}>DRAG TO ROTATE · SCROLL TO ZOOM</span>
        </div>
      )}
    </div>
  );
}

/* ────────────────────────────── Final preview
   With a real STL → live Three.js viewer. Without (e.g. OrderPage cart
   item that has no stl_url) → the stylised lid-with-sticker fallback. */
function FinalPreview({ seed, t, city, stlUrl, label }) {
  if (stlUrl) {
    return <STLViewer url={stlUrl} accent={city?.accent} label={label}/>;
  }
  return (
    <div style={{
      width:'76%', aspectRatio:'1/1', position:'relative',
      transform:'perspective(1100px) rotateX(34deg) rotateY(-8deg)',
      transformStyle:'preserve-3d',
      animation: 'cf-float 5s ease-in-out infinite',
    }}>
      {/* Mist lid base */}
      <div style={{
        position:'absolute', inset:'-12% -9% -9% -9%',
        background:'var(--bone)',
        boxShadow:'0 50px 50px -28px rgba(0,0,0,0.4), inset 0 0 0 1px var(--line-strong)',
        transform:'translateZ(-30px)',
      }}/>
      {/* Top face — flat Mist */}
      <div style={{ position:'absolute', inset:0, background:'var(--mist)', border:'1px solid var(--line-strong)' }}>
        {/* The sticker centred */}
        <div style={{ position:'absolute', inset:'15%', display:'flex', alignItems:'center', justifyContent:'center' }}>
          <CitySticker city={city} size={260}/>
        </div>
      </div>
    </div>
  );
}

/* ────────────────────────────── CART */
function CartPage({ go, cart, removeFromCart, checkout, checkoutMsg, t }) {
  if (cart.length === 0) {
    return (
      <div style={{ background:'var(--mist)', minHeight:'70vh', display:'flex', alignItems:'center', justifyContent:'center' }}>
        <div style={{ textAlign:'center' }}>
          <h1 className="h1" style={{ fontSize:64 }}>Your bag is empty.</h1>
          <p className="body" style={{ fontSize:17, color:'var(--stone)', marginTop:14, marginBottom:32 }}>The collection is built to order, in Sheffield.</p>
          <div style={{ display:'flex', gap:12, justifyContent:'center' }}>
            <button className="btn btn-primary" onClick={() => go('atlas')}>See the collection</button>
          </div>
        </div>
      </div>
    );
  }
  const subtotal = cart.reduce((sum, i) => sum + (i.price || 95) * (i.qty || 1), 0);
  const shipping = subtotal >= 50 ? 0 : 3.49;
  return (
    <div style={{ background:'var(--mist)' }}>
      <div className="container" style={{ paddingTop:48, paddingBottom:96 }}>
        <div className="label">Bag</div>
        <h1 className="h1" style={{ fontSize:72, marginTop:14, marginBottom:40 }}>Almost there.</h1>
        <div style={{ display:'grid', gridTemplateColumns:'1.6fr 1fr', gap:48 }}>
          {/* line items */}
          <div style={{ border:'1px solid var(--line)', background:'var(--bone)' }}>
            {cart.map((line, i) => (
              <div key={line.lineId || line.id + i} style={{
                display:'grid', gridTemplateColumns:'120px 1fr auto auto', gap:20,
                padding:'20px', borderBottom: i < cart.length - 1 ? '1px solid var(--line)' : 'none',
                alignItems:'center',
              }}>
                {line.accent ? (
                  <div style={{ width:110, height:110 }}><CitySticker city={line} size={110}/></div>
                ) : (
                  <div style={{ width:110, height:110, background:'var(--mist)', border:'1px solid var(--line-strong)', display:'flex', alignItems:'center', justifyContent:'center' }}>
                    <span className="label" style={{ fontSize:9 }}>—</span>
                  </div>
                )}
                <div>
                  <div style={{ display:'flex', alignItems:'center', gap:10 }}>
                    <div className="h3" style={{ fontSize:20, fontWeight:600 }}>{line.name}</div>
                    {line.custom && <span style={{ fontSize:9, letterSpacing:'0.22em', fontWeight:500, padding:'3px 8px', background:'var(--ink)', color:'var(--mist)' }}>COMMISSION</span>}
                  </div>
                  <div className="caption" style={{ fontSize:11, marginTop:6 }}>
                    {window.fmtCoord(line.coords.ns === 'S' ? -line.coords.lat : line.coords.lat, line.coords.ew === 'W' ? -line.coords.lng : line.coords.lng)}
                  </div>
                  <div style={{ marginTop:10, display:'flex', gap:18, fontSize:12 }}>
                    <span><span className="label" style={{ fontSize:9 }}>FORMAT</span> &nbsp;9 × 9 cm</span>
                    <span><span className="label" style={{ fontSize:9 }}>SCALE</span> &nbsp;1:11000</span>
                    {line.landmark && <span><span className="label" style={{ fontSize:9 }}>LANDMARK</span> &nbsp;{line.landmark}</span>}
                  </div>
                </div>
                <div style={{ fontSize:15, fontVariantNumeric:'tabular-nums' }}>£{line.price || 95}</div>
                <button onClick={() => removeFromCart(i)} style={{ color:'var(--stone)', padding:8 }}>{Icon.close}</button>
              </div>
            ))}
          </div>

          {/* summary */}
          <div style={{ position:'sticky', top:96, alignSelf:'flex-start' }}>
            <div style={{ background:'var(--bone)', border:'1px solid var(--ink)', padding:24 }}>
              <div className="label" style={{ marginBottom:16 }}>Summary</div>
              {[
                ['Subtotal',  `£${subtotal.toFixed(2)}`],
                ['Shipping (Royal Mail Tracked)', shipping === 0 ? 'Free over £50' : `from £${shipping.toFixed(2)}`],
              ].map(([k, v]) => (
                <div key={k} style={{ display:'flex', justifyContent:'space-between', padding:'8px 0', fontSize:13 }}>
                  <span style={{ color:'var(--stone)' }}>{k}</span>
                  <span style={{ fontVariantNumeric:'tabular-nums' }}>{v}</span>
                </div>
              ))}
              <div style={{ marginTop:8, paddingTop:14, borderTop:'1px solid var(--ink)',
                display:'flex', justifyContent:'space-between', alignItems:'baseline' }}>
                <div className="label">Subtotal</div>
                <div className="h1" style={{ fontSize:32, fontVariantNumeric:'tabular-nums' }}>£{subtotal.toFixed(2)}</div>
              </div>
              <button className="btn btn-primary" style={{ width:'100%', marginTop:20 }}
                disabled={checkoutMsg === 'loading'}
                onClick={() => checkout && checkout()}>
                {checkoutMsg === 'loading' ? 'Redirecting…' : <>Secure checkout &nbsp;{Icon.arrow}</>}
              </button>
              {checkoutMsg && checkoutMsg !== 'loading' && (
                <div style={{ marginTop:12, fontSize:12, color:'var(--vermilion)' }}>{checkoutMsg}</div>
              )}
              <div style={{ marginTop:16, fontSize:11, color:'var(--stone)', lineHeight:1.5 }}>
                Shipping is chosen and payment is taken securely on the next step (Stripe). Tracked 48 is free over £50.
              </div>
            </div>
            <div style={{ marginTop:20, padding:'16px 20px', background:'var(--mist)', borderLeft:'2px solid var(--ink)', fontSize:13, color:'var(--stone)', lineHeight:1.55 }}>
              Every cityform model is made to order in Sheffield. Typical lead time is about 7 days from payment to dispatch.
            </div>
          </div>
        </div>
      </div>
      <Footer go={go}/>
    </div>
  );
}

/* ────────────────────────────── ORDER CONFIRMATION */
function OrderPage({ go, cart, t }) {
  const line = cart[0] || {};
  const ref = line.commission_ref || null;
  const etsyUrl = line.etsy_url || null;
  const place = line.name || 'your city';
  const STAGES = [
    { id:'saved',   label:'Design saved',                 done: true,  active: false },
    { id:'pay',     label:'Pay on Etsy',                  done: false, active: true },
    { id:'queued',  label:'In production',                done: false },
    { id:'ship',    label:'Shipped',                      done: false },
  ];
  return (
    <div style={{ background:'var(--mist)' }}>
      <div className="container" style={{ paddingTop:64, paddingBottom:96, maxWidth:1080 }}>
        <div style={{ textAlign:'center', maxWidth:620, margin:'0 auto' }}>
          <div className="label">Almost there</div>
          <h1 className="h1" style={{ fontSize:64, marginTop:14, marginBottom:14 }}>Your design is saved.</h1>
          <p className="body" style={{ fontSize:16, color:'var(--stone)' }}>
            Complete payment on Etsy to confirm — Etsy handles secure checkout,
            shipping &amp; returns. We start production once Etsy confirms payment.
          </p>
          {ref && (
            <div style={{ marginTop:24, display:'inline-block', textAlign:'left',
              background:'var(--bone)', border:'1px solid var(--ink)', padding:'16px 22px' }}>
              <div className="label" style={{ fontSize:9 }}>Your order reference</div>
              <div style={{ display:'flex', alignItems:'center', gap:16, marginTop:6 }}>
                <div className="h1" style={{ fontSize:30, fontWeight:600, fontVariantNumeric:'tabular-nums', letterSpacing:'0.04em' }}>{ref}</div>
                <button className="btn-link" style={{ fontSize:11 }}
                  onClick={() => { try { navigator.clipboard.writeText(ref); } catch (e) {} }}>Copy</button>
              </div>
            </div>
          )}
          <div style={{ marginTop:28, textAlign:'left', maxWidth:520, margin:'28px auto 0' }}>
            {[
              ['1', 'Open the Etsy listing', etsyUrl ? 'Tap the button below.' : 'Email the studio with your reference.'],
              ['2', 'Paste your reference', ref ? `Put ${ref} in Etsy's “Note to seller” at checkout so we match your exact ${place} design.` : 'Quote your design when you contact us.'],
              ['3', 'Pay on Etsy', 'Production starts as soon as Etsy confirms payment. Lead time ~8 weeks.'],
            ].map(([n, h, b]) => (
              <div key={n} style={{ display:'flex', gap:14, padding:'10px 0', borderBottom:'1px dotted var(--line-strong)' }}>
                <div style={{ width:26, height:26, flexShrink:0, border:'1.5px solid var(--ink)',
                  display:'flex', alignItems:'center', justifyContent:'center', fontSize:11, fontWeight:600 }}>{n}</div>
                <div><div style={{ fontSize:14, fontWeight:600 }}>{h}</div>
                  <div style={{ fontSize:12, color:'var(--stone)', marginTop:3, lineHeight:1.5 }}>{b}</div></div>
              </div>
            ))}
          </div>
          <div style={{ marginTop:24 }}>
            {etsyUrl ? (
              <button className="btn btn-primary" onClick={() => window.open(etsyUrl, '_blank', 'noopener')}>
                Open the Etsy listing &nbsp;{Icon.arrow}
              </button>
            ) : (
              <a className="btn btn-primary" style={{ display:'inline-flex' }}
                href={`mailto:studio@cityform.co?subject=${encodeURIComponent('Cityform commission ' + (ref || ''))}`}>
                Email studio@cityform.co &nbsp;{Icon.arrow}
              </a>
            )}
          </div>
        </div>

        {/* Production tracker */}
        <div style={{ marginTop:56, padding:'32px', background:'var(--bone)', border:'1px solid var(--line)' }}>
          <div className="label">Where this goes</div>
          <h2 className="h1" style={{ fontSize:32, marginTop:14, marginBottom:32 }}>Saved → paid → made → shipped.</h2>
          <div style={{ display:'grid', gridTemplateColumns:`repeat(${STAGES.length}, 1fr)`, gap:0, alignItems:'flex-start' }}>
            {STAGES.map((s, i) => (
              <div key={s.id} style={{ position:'relative', textAlign:'center' }}>
                {i < STAGES.length - 1 && (
                  <div style={{ position:'absolute', top:14, left:'55%', width:'90%', height:1,
                    background: s.done ? 'var(--ink)' : 'var(--line)' }}/>
                )}
                <div style={{
                  width:30, height:30, margin:'0 auto',
                  border:`1.5px solid ${s.done || s.active ? 'var(--ink)' : 'var(--line-strong)'}`,
                  background: s.done ? 'var(--ink)' : 'var(--bone)',
                  color: s.done ? 'var(--mist)' : 'var(--ink)',
                  display:'flex', alignItems:'center', justifyContent:'center',
                  fontSize:11, fontWeight:600, fontVariantNumeric:'tabular-nums',
                  position:'relative', zIndex:1,
                }}>{s.done ? '✓' : (i + 1).toString().padStart(2,'0')}</div>
                <div style={{ marginTop:12, fontSize:12, color: s.done || s.active ? 'var(--ink)' : 'var(--stone)', fontWeight: s.active ? 600 : 500 }}>
                  {s.label}
                </div>
                {s.active && <div style={{ fontSize:9, letterSpacing:'0.22em', fontWeight:500, marginTop:6, color:'var(--vermilion)' }}>AWAITING ETSY PAYMENT</div>}
              </div>
            ))}
          </div>

          <div style={{ marginTop:40, display:'grid', gridTemplateColumns:'1fr 1.4fr', gap:32, alignItems:'center' }}>
            <div style={{ aspectRatio:'1/1', background:'#0F1216', position:'relative' }}>
              {cart[0] && (
                <FinalPreview seed={(cart[0].name || 'cf') + '-cart'} t={t} city={cart[0]} stlUrl={cart[0].stl_url} label={cart[0].label}/>
              )}
            </div>
            <div>
              <div className="label">Your design</div>
              <h3 className="h1" style={{ fontSize:30, marginTop:14, marginBottom:18, fontWeight:600 }}>{place}.</h3>
              <div style={{ borderTop:'1px solid var(--line)' }}>
                {[
                  ['Reference', ref || '—'],
                  ['Format', '9 × 9 × 1.6 cm · 1:11000'],
                  ['Material', 'PETG · Mist · hand-finished'],
                  ['Lead time', 'Eight weeks from payment'],
                ].map(([k, v]) => (
                  <div key={k} style={{ display:'flex', justifyContent:'space-between', padding:'10px 0', borderBottom:'1px dotted var(--line-strong)', fontSize:13 }}>
                    <span style={{ color:'var(--stone)' }}>{k}</span>
                    <span style={{ fontVariantNumeric:'tabular-nums' }}>{v}</span>
                  </div>
                ))}
              </div>
              <div style={{ marginTop:24, display:'flex', gap:10 }}>
                <button className="btn btn-ghost" onClick={() => go('home')}>Continue browsing</button>
                <button className="btn btn-primary" onClick={() => go('atelier')}>Commission another</button>
              </div>
            </div>
          </div>
        </div>

        <div style={{ marginTop:24, padding:'16px 20px', background:'var(--bone)',
          borderLeft:'2px solid var(--ink)', fontSize:13, color:'var(--stone)', lineHeight:1.55 }}>
          Questions about your commission? Email <span style={{ color:'var(--ink)' }}>studio@cityform.co</span> with
          your reference{ref ? ` ${ref}` : ''}. Payment, delivery address &amp; receipts are handled on Etsy.
        </div>
      </div>
      <Footer go={go}/>
    </div>
  );
}

Object.assign(window, { StepConfirm, GenerationWireframe, STLViewer, FinalPreview, CartPage, OrderPage });
