// CONY · Soft Seoul — UI components

const { useState, useEffect, useRef, useMemo } = React;

/* ================= world dot pattern ================= */
// Rough continent polygons in [lon, lat] — projected via window.PROJECT.
// Hand-traced for an old-paper map feel.
const CONTINENTS = [
  // North America
  [[-168,66],[-156,71],[-140,71],[-130,70],[-120,70],[-110,73],[-95,76],[-82,82],[-70,80],[-62,75],[-55,68],[-58,60],[-65,55],[-58,52],[-52,47],[-65,44],[-67,46],[-70,42],[-75,38],[-80,32],[-82,27],[-80,25],[-83,24],[-90,29],[-95,29],[-97,26],[-105,22],[-107,24],[-115,30],[-122,35],[-124,40],[-124,46],[-130,52],[-135,58],[-152,60],[-160,55],[-168,60],[-168,66]],
  // Greenland
  [[-50,60],[-45,64],[-30,68],[-22,72],[-22,79],[-30,82],[-50,82],[-58,76],[-55,68],[-50,60]],
  // South America
  [[-78,12],[-70,12],[-60,10],[-52,5],[-50,0],[-48,-3],[-40,-5],[-35,-8],[-38,-15],[-38,-22],[-43,-25],[-48,-28],[-58,-35],[-62,-39],[-66,-45],[-70,-52],[-72,-55],[-75,-50],[-73,-42],[-75,-35],[-72,-28],[-70,-20],[-72,-12],[-78,-6],[-80,-2],[-78,5],[-78,12]],
  // Europe
  [[-10,36],[-5,43],[0,44],[5,43],[8,44],[12,38],[15,40],[20,40],[26,40],[28,41],[30,40],[36,36],[40,40],[42,44],[40,48],[35,50],[30,52],[28,56],[25,60],[22,64],[18,68],[20,70],[28,71],[35,69],[42,67],[50,67],[55,68],[60,69],[70,70],[68,72],[55,75],[42,72],[35,68],[30,65],[20,63],[12,58],[8,54],[2,50],[-5,50],[-9,52],[-10,54],[-3,55],[-2,58],[-5,58],[-10,55],[-12,52],[-10,48],[-5,45],[-10,42],[-10,36]],
  // Africa
  [[-17,15],[-15,20],[-10,28],[-2,35],[5,33],[10,32],[16,31],[24,31],[30,31],[33,28],[35,22],[37,18],[40,12],[44,8],[48,12],[51,11],[51,5],[42,-2],[40,-7],[40,-15],[38,-22],[33,-28],[28,-31],[20,-34],[18,-34],[15,-30],[12,-23],[12,-15],[14,-8],[10,-2],[8,5],[3,5],[-5,5],[-10,8],[-15,12],[-17,15]],
  // Asia
  [[40,40],[45,40],[50,38],[55,37],[60,36],[65,35],[70,34],[75,32],[78,30],[82,28],[88,26],[92,22],[95,22],[100,22],[105,21],[108,18],[110,21],[115,22],[120,24],[122,30],[121,35],[124,40],[127,42],[130,46],[135,48],[140,55],[150,60],[160,62],[170,66],[175,68],[180,70],[180,73],[170,72],[155,70],[140,73],[130,73],[120,75],[100,76],[85,77],[70,76],[60,72],[55,68],[55,55],[50,50],[45,45],[40,42],[40,40]],
  // Asia SE / Indochina + India + Arabia
  [[42,12],[45,18],[48,24],[55,26],[60,25],[65,25],[68,24],[72,21],[73,15],[76,10],[78,8],[80,7],[82,8],[85,12],[88,21],[92,21],[95,16],[97,12],[100,8],[103,2],[105,1],[108,2],[105,8],[107,12],[109,15],[107,20],[100,18],[95,16],[90,22],[88,26],[80,18],[76,12],[72,18],[70,22],[68,16],[60,18],[55,20],[50,18],[45,15],[42,12]],
  // Australia
  [[114,-22],[120,-20],[125,-15],[130,-12],[136,-12],[140,-12],[143,-15],[148,-20],[152,-25],[150,-32],[148,-37],[142,-38],[138,-35],[130,-32],[125,-32],[118,-33],[114,-30],[112,-25],[114,-22]],
  // New Zealand (rough)
  [[170,-35],[174,-37],[176,-39],[174,-42],[170,-45],[167,-45],[166,-42],[168,-38],[170,-35]],
  // British Isles
  [[-10,50],[-5,50],[-2,52],[-1,55],[-3,58],[-6,58],[-8,55],[-10,52],[-10,50]],
  // Japan
  [[131,32],[135,33],[139,35],[141,38],[142,42],[145,44],[143,45],[140,42],[136,36],[132,33],[131,32]],
  // Madagascar
  [[44,-15],[48,-13],[50,-18],[50,-25],[46,-26],[44,-20],[44,-15]],
];

function WorldBackdrop() {
  const paths = useMemo(() => CONTINENTS.map(poly => {
    return poly.map(([lon, lat], i) => {
      const { x, y } = window.PROJECT(lat, lon);
      return (i === 0 ? 'M' : 'L') + x.toFixed(2) + ' ' + y.toFixed(2);
    }).join(' ') + ' Z';
  }), []);
  return (
    <svg className="map-svg world-map" viewBox="0 0 100 100" preserveAspectRatio="none">
      <defs>
        <radialGradient id="ocean-soft" cx="50%" cy="55%" r="75%">
          <stop offset="0%" stopColor="#e6e9ec"/>
          <stop offset="60%" stopColor="#d8dee2"/>
          <stop offset="100%" stopColor="#c9d2d6"/>
        </radialGradient>
      </defs>
      {/* soft ocean wash */}
      <rect x="0" y="0" width="100" height="100" fill="url(#ocean-soft)"/>
      {/* one quiet equator hairline */}
      <line x1="6" y1="50" x2="94" y2="50" stroke="rgba(43,37,31,0.08)" strokeWidth="0.08" strokeDasharray="0.6 0.9"/>
      {paths.map((d, i) => (
        <path key={i} d={d} className="continent" vectorEffect="non-scaling-stroke" />
      ))}
    </svg>
  );
}

/* ================= icons ================= */
const TransportIcon = ({ id }) => {
  if (id === 'plane') return (
    /* airliner — fuselage + swept wings + tail */
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
      <path d="M2.5 14.5 L10.5 12.5 L13 5 a1 1 0 0 1 2 0 L15.8 12 L21.5 13.6 L21.5 15.2 L15 14.6 L13.5 19 L11.8 19 L12.2 14.4 L8.5 14.2 L7 16 L5.5 16 L6 14.5 L2.5 14.5 Z" fill="currentColor" stroke="none"/>
    </svg>
  );
  if (id === 'subway') return (
    /* high-speed train — bullet nose */
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
      <path d="M3 17 Q3 9 12 6 Q21 9 21 17 L21 17.5 L3 17.5 Z" fill="currentColor" fillOpacity="0.18"/>
      <path d="M5.5 11.5 L10.5 10 M18.5 11.5 L13.5 10"/>
      <line x1="3.5" y1="14" x2="20.5" y2="14"/>
      <circle cx="7" cy="19" r="1"/>
      <circle cx="17" cy="19" r="1"/>
      <line x1="2" y1="20.5" x2="22" y2="20.5"/>
    </svg>
  );
  if (id === 'ship') return (
    /* cruise ship — hull + superstructure + funnel + waves */
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
      <path d="M2.5 16 L4 13 L20 13 L21.5 16 Z" fill="currentColor" fillOpacity="0.18"/>
      <rect x="6" y="9" width="12" height="4" rx="0.5"/>
      <line x1="8" y1="11" x2="9.5" y2="11"/>
      <line x1="11" y1="11" x2="12.5" y2="11"/>
      <line x1="14" y1="11" x2="15.5" y2="11"/>
      <rect x="13.5" y="6" width="2" height="3" rx="0.3"/>
      <path d="M2 19 q1.5 -1.2 3 0 t3 0 t3 0 t3 0 t3 0 t3 0"/>
    </svg>
  );
  /* bus — boxy with windows & wheels */
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
      <rect x="3.5" y="5" width="17" height="12" rx="2" fill="currentColor" fillOpacity="0.15"/>
      <line x1="3.5" y1="11" x2="20.5" y2="11"/>
      <line x1="9" y1="5" x2="9" y2="11"/>
      <line x1="14.5" y1="5" x2="14.5" y2="11"/>
      <rect x="5.5" y="13" width="3" height="2.2" rx="0.3" fill="currentColor" stroke="none"/>
      <rect x="15.5" y="13" width="3" height="2.2" rx="0.3" fill="currentColor" stroke="none"/>
      <circle cx="7.5" cy="18.5" r="1.5"/>
      <circle cx="16.5" cy="18.5" r="1.5"/>
    </svg>
  );
};

/* on-map vehicle — clearly silhouetted, rotates to direction */
const VehicleGlyph = ({ id, angle = 0 }) => {
  const style = { transform: `rotate(${angle}deg)` };
  if (id === 'plane') return (
    <svg viewBox="0 0 24 24" style={style} fill="currentColor">
      <path d="M2 12 L20.5 5 L21.5 6 L13 12 L21.5 18 L20.5 19 L2 12 Z M11 12 L8 16 L7 16 L8 12 L7 8 L8 8 Z"/>
    </svg>
  );
  if (id === 'subway') return (
    <svg viewBox="0 0 24 24" style={style} fill="currentColor">
      <path d="M3 12 Q3 8 14 7 L21 11.5 L21 12.5 L14 17 Q3 16 3 12 Z" fillOpacity="0.95"/>
      <rect x="6" y="10" width="2" height="2" rx="0.3" fill="#fff" fillOpacity="0.6"/>
      <rect x="10" y="10" width="2" height="2" rx="0.3" fill="#fff" fillOpacity="0.6"/>
    </svg>
  );
  if (id === 'ship') return (
    <svg viewBox="0 0 24 24" style={style} fill="currentColor">
      <path d="M3 14 L5 11 L19 11 L21 14 Z"/>
      <rect x="8" y="7" width="9" height="4" rx="0.4"/>
      <rect x="13" y="4" width="1.6" height="3" rx="0.2"/>
    </svg>
  );
  /* bus */
  return (
    <svg viewBox="0 0 24 24" style={style} fill="currentColor">
      <rect x="3" y="7" width="18" height="10" rx="1.6"/>
      <rect x="5" y="9" width="3" height="3" rx="0.3" fill="#fff" fillOpacity="0.7"/>
      <rect x="9" y="9" width="3" height="3" rx="0.3" fill="#fff" fillOpacity="0.7"/>
      <rect x="13" y="9" width="3" height="3" rx="0.3" fill="#fff" fillOpacity="0.7"/>
      <rect x="17" y="9" width="2" height="3" rx="0.3" fill="#fff" fillOpacity="0.7"/>
    </svg>
  );
};

const StepIcon = ({ id }) => {
  if (id === 'pick') return (
    <svg viewBox="0 0 24 24"><circle cx="12" cy="11" r="3.5"/><path d="M12 22s8-7.5 8-13a8 8 0 10-16 0c0 5.5 8 13 8 13z"/></svg>
  );
  if (id === 'transit') return (
    <svg viewBox="0 0 24 24"><path d="M3 17h18M5 13l3-7h8l3 7M7 13v4h10v-4"/><circle cx="8" cy="17" r="1.5"/><circle cx="16" cy="17" r="1.5"/></svg>
  );
  return (
    <svg viewBox="0 0 24 24"><path d="M3 12h12l-3-3M3 12l3 3M19 5v14"/></svg>
  );
};

/* ================= top bar ================= */
/* ===== daily sale banner (top of map) ===== */

/* ===== photo album · 街头偶遇合影 + 玩家自上传 ===== */
function PhotoAlbum({ photos, onAdd, onDelete, onClose }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const A = lang === 'zh' ? {
    tooBig:'图片请小于 1.8MB',
    askCap:'给这张照片起一句话说明：',
    defaultCap:'一段难忘的旅行',
    onTheRoad:'途中',
    delTip:'删除',
  } : {
    tooBig:'Image must be under 1.8 MB',
    askCap:'A short caption for this photo:',
    defaultCap:'A trip to remember',
    onTheRoad:'in transit',
    delTip:T('btn.delete'),
  };
  const fileRef = useRef(null);
  const onFile = (e) => {
    const f = e.target.files?.[0];
    if (!f) return;
    if (f.size > 1.8 * 1024 * 1024) { (window.conyAlert || window.alert)(A.tooBig); return; }
    const r = new FileReader();
    r.onload = () => {
      const cap = prompt(A.askCap, A.defaultCap) || '';
      onAdd?.({
        id: `pa-${Date.now()}-${Math.random().toString(36).slice(2,5)}`,
        type: 'upload',
        cityName: currentCity?.name_zh || A.onTheRoad,
        image: r.result,
        caption: cap,
        ts: Date.now(),
      });
    };
    r.readAsDataURL(f);
  };
  // group by city
  const byCity = {};
  for (const p of photos) {
    const k = p.cityName || A.onTheRoad;
    byCity[k] = byCity[k] || [];
    byCity[k].push(p);
  }
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal photo-album" onClick={e=>e.stopPropagation()}>
        <div className="modal-head">
          <div className="modal-title">📷 {T('album.title')}</div>
          <button className="modal-close" onClick={onClose}>×</button>
        </div>
        <div className="modal-summary">
          <strong>{photos.length}</strong> · <strong>{Object.keys(byCity).length}</strong>
        </div>
        <div className="modal-body">
          {photos.length === 0 && (
            <div className="empty">{T('album.empty')}</div>
          )}
          {Object.entries(byCity).map(([city, list]) => (
            <div key={city} className="album-group">
              <div className="album-group-title">{city}</div>
              <div className="album-grid">
                {list.map(p => (
                  <div key={p.id} className="album-photo">
                    <img src={p.image} alt={p.caption}/>
                    <div className="ap-meta">
                      <div className="ap-cap">{p.caption || '·'}</div>
                      <div className="ap-date">{new Date(p.ts).toISOString().slice(0,10)}</div>
                      <button className="ap-del" onClick={() => onDelete?.(p.id)} title={A.delTip}>×</button>
                    </div>
                  </div>
                ))}
              </div>
            </div>
          ))}
        </div>
        <div className="modal-actions">
          <input ref={fileRef} type="file" accept="image/*" onChange={onFile} hidden/>
          <button className="btn primary" onClick={() => fileRef.current?.click()}>{T('album.upload')}</button>
        </div>
      </div>
    </div>
  );
}

/* ===== travel diary · 自由文字日记 (含城市/国家选择器) ===== */
function Diary({ entries, currentCityName, currentCountryId, onAdd, onUpdate, onDelete }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const D = lang === 'zh' ? {
    write:'✎ 写一条日记', cancel:'取消', save:'写下', update:'更新',
    edit:'编辑', delete:'删除', confirmDel:'删除这条日记？删除后无法恢复。',
    pickCity:'选一个城市', searchCity:'搜索城市…',
    titlePh: (n) => `标题（默认：${n || '途中'} 的一天）`,
    onTheRoad:'途中', dayOf: (n) => `${n} 的一天`,
    bodyPh:'在这里看见、听见、吃到、遇见了什么…',
    empty:'还没有日记。', emptyHint:'路上的小心情，记下来下次旅行回来再读一遍。',
    delTitle:'删除', editTitle:'编辑', visibility:'可见性', priv:'私密', pub:'公开',
    editing:'编辑中',
  } : lang === 'ko' ? {
    write:'✎ 새 일기', cancel:'취소', save:'저장', update:'수정',
    edit:'편집', delete:'삭제', confirmDel:'이 일기를 삭제할까요? 되돌릴 수 없어요.',
    pickCity:'도시 선택', searchCity:'검색…',
    titlePh: (n) => `제목 (기본: ${n || '여행 중'}의 하루)`,
    onTheRoad:'여행 중', dayOf: (n) => `${n}의 하루`,
    bodyPh:'무엇을 보고 듣고 먹고 만났는지…',
    empty:'아직 일기가 없어요.', emptyHint:'길 위의 작은 감정을 기록해 보세요.',
    delTitle:'삭제', editTitle:'편집', visibility:'공개 설정', priv:'비공개', pub:'공개',
    editing:'편집 중',
  } : {
    write:'✎ Write entry', cancel:T('btn.cancel'), save:T('btn.save'), update:'Update',
    edit:'Edit', delete:'Delete', confirmDel:'Delete this entry? This cannot be undone.',
    pickCity:'Pick a city', searchCity:'Search…',
    titlePh: (n) => `Title (default: a day in ${n || 'transit'})`,
    onTheRoad:'in transit', dayOf: (n) => `A day in ${n}`,
    bodyPh:'What did you see / hear / taste / meet here…',
    empty:'No entries yet.', emptyHint:'Note small moods on the road — re-read after the trip.',
    delTitle:T('btn.delete'), editTitle:'Edit', visibility:'Visibility', priv:'Private', pub:'Public',
    editing:'Editing',
  };
  const [open, setOpen] = useState(false);
  const [editingId, setEditingId] = useState(null);
  const [title, setTitle] = useState('');
  const [mood, setMood] = useState('🙂');
  const [body, setBody] = useState('');
  const [visibility, setVisibility] = useState('private');
  const [cityPickOpen, setCityPickOpen] = useState(false);
  const [pickedCity, setPickedCity] = useState({ name: currentCityName || '', country: currentCountryId || '' });
  // when current city changes, default the picker to it (only if user hasn't manually picked)
  useEffect(() => {
    if (!open) {
      setPickedCity({ name: currentCityName || '', country: currentCountryId || '' });
    }
  }, [currentCityName, currentCountryId]); // eslint-disable-line

  const allCities = (window.CITIES || []).map(c => ({
    id: c.id,
    label: lang === 'zh' ? (c.name_zh || c.name) : (c.name || c.name_zh),
    country: c.country,
  }));
  const resetForm = () => {
    setEditingId(null);
    setTitle('');
    setMood('🙂');
    setBody('');
    setVisibility('private');
    setOpen(false);
  };
  const startEdit = (entry) => {
    setEditingId(entry.id);
    setTitle(entry.title || '');
    setMood(entry.mood || '🙂');
    setBody(entry.body || '');
    setVisibility(entry.visibility || 'private');
    setPickedCity({ name: entry.cityName || '', country: entry.countryId || '' });
    setOpen(true);
  };
  const askDelete = async (entry) => {
    const confirmFn = window.conyConfirm || ((m) => Promise.resolve(window.confirm(m)));
    if (!(await confirmFn(D.confirmDel, { tone: 'danger' }))) return;
    onDelete?.(entry.id);
    // Mirror the deletion to Supabase if the entry was ever public.
    if (entry.visibility === 'public') {
      const supa = window.SUPA;
      const auth = window.__conyAuth || {};
      if (supa && auth.user) {
        try {
          await supa.from('diary_entries')
            .delete()
            .eq('user_id', auth.user.id)
            .eq('client_id', entry.id);
        } catch (_) { /* offline or RLS not set up — local delete still applies */ }
      }
    }
  };
  const submit = async () => {
    if (!body.trim()) return;
    const isEdit = !!editingId;
    const entry = {
      id: isEdit ? editingId : `d-${Date.now()}-${Math.random().toString(36).slice(2,5)}`,
      title: title.trim() || D.dayOf(pickedCity.name || D.onTheRoad),
      mood,
      body: body.trim(),
      cityName: pickedCity.name || '',
      countryId: pickedCity.country || '',
      visibility,
      ts: isEdit ? (entries.find(x => x.id === editingId)?.ts || Date.now()) : Date.now(),
    };
    if (isEdit) onUpdate?.(editingId, entry);
    else onAdd?.(entry);
    // Sync to Supabase. Public → upsert; if a previously-public entry was made
    // private (only possible via edit), remove its public row.
    const supa = window.SUPA;
    const auth = window.__conyAuth || {};
    if (supa && auth.user) {
      try {
        if (visibility === 'public') {
          await supa.from('diary_entries').upsert({
            user_id: auth.user.id,
            client_id: entry.id,
            title: entry.title,
            body: entry.body,
            mood: entry.mood,
            city_name: entry.cityName,
            country_id: entry.countryId,
            visibility: 'public',
          }, { onConflict: 'user_id,client_id' });
        } else if (isEdit) {
          await supa.from('diary_entries')
            .delete()
            .eq('user_id', auth.user.id)
            .eq('client_id', entry.id);
        }
      } catch (_) { /* offline / RLS not set — local data is authoritative */ }
    }
    resetForm();
  };

  return (
    <div className="diary">
      {!open && (
        <button className="diary-write-btn" type="button" onClick={() => setOpen(true)}>
          {D.write}
        </button>
      )}
      {open && (
        <div className={`diary-compose ${editingId ? 'editing' : ''}`}>
          {editingId && (
            <div className="dc-editing-tag">✎ {D.editing}</div>
          )}
          <div className="dc-row">
            <select value={mood} onChange={e=>setMood(e.target.value)} className="dc-mood">
              {['🙂','😌','🥲','😍','😴','🤔','🌧','✨','😎','🥳'].map(m =>
                <option key={m} value={m}>{m}</option>
              )}
            </select>
            <button className="dc-city-pick" type="button" onClick={() => setCityPickOpen(o => !o)}>
              {pickedCity.country ? <span className="dccp-flag">{flagOf(pickedCity.country)}</span> : null}
              <span className="dccp-name">{pickedCity.name || D.pickCity}</span>
              <span className="dccp-arrow">▾</span>
            </button>
          </div>
          {cityPickOpen && (
            <div className="dc-city-list">
              <input type="text"
                     placeholder={D.searchCity}
                     className="dc-city-search"
                     onInput={e => {
                       const q = e.target.value.toLowerCase();
                       e.target.parentElement.querySelectorAll('.dc-city-opt').forEach(el => {
                         el.style.display = el.dataset.search.includes(q) ? '' : 'none';
                       });
                     }}/>
              {allCities.map(c => (
                <button key={c.id} type="button"
                        className="dc-city-opt"
                        data-search={(c.label + ' ' + c.country).toLowerCase()}
                        onClick={() => { setPickedCity({ name: c.label, country: c.country }); setCityPickOpen(false); }}>
                  <span className="dccp-flag">{flagOf(c.country)}</span>
                  <span>{c.label}</span>
                </button>
              ))}
            </div>
          )}
          <input
            type="text"
            value={title}
            onChange={e=>setTitle(e.target.value)}
            placeholder={D.titlePh(pickedCity.name)}
            maxLength={40}
            className="dc-title"
            style={{ marginTop: 8 }}
          />
          <textarea
            value={body}
            onChange={e=>setBody(e.target.value)}
            placeholder={D.bodyPh}
            rows={4}
            maxLength={500}
            className="dc-body"
          />
          <div className="dc-row" style={{ marginTop: 6 }}>
            <span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{D.visibility}</span>
            <button type="button"
                    className={`chip ${visibility === 'private' ? 'active' : ''}`}
                    onClick={() => setVisibility('private')}>🔒 {D.priv}</button>
            <button type="button"
                    className={`chip ${visibility === 'public' ? 'active' : ''}`}
                    onClick={() => setVisibility('public')}>🌐 {D.pub}</button>
          </div>
          <div className="dc-footer">
            <span className="dc-count">{body.length}/500</span>
            <div className="dc-buttons">
              <button className="btn ghost" type="button" onClick={resetForm}>{D.cancel}</button>
              <button className="btn primary" type="button" onClick={submit} disabled={!body.trim()}>
                {editingId ? D.update : D.save}
              </button>
            </div>
          </div>
        </div>
      )}
      <div className="diary-persist-note">
        {lang === 'zh' ? '🔒 日记跨局保留，只有你手动删除才会消失'
          : lang === 'ko' ? '🔒 일기는 게임을 새로 시작해도 남아 있어요. 직접 삭제할 때만 사라집니다.'
          : lang === 'ja' ? '🔒 日記は次の旅にも引き継がれます。削除しない限り残ります。'
          : '🔒 Diary entries survive new runs — they only disappear when you delete them.'}
      </div>
      <div className="diary-timeline">
        {entries.length === 0 && (
          <div className="empty" style={{padding:'18px 0'}}>{D.empty}<br/>{D.emptyHint}</div>
        )}
        {entries.map(e => (
          <div key={e.id} className="diary-entry">
            <div className="de-line"/>
            <div className="de-dot">{e.mood}</div>
            <div className="de-card">
              <div className="de-title">
                {e.countryId && <span className="de-flag">{flagOf(e.countryId)}</span>}
                {e.title}
                {e.visibility === 'public' && <span className="de-vis"> · 🌐</span>}
                {e.visibility === 'private' && <span className="de-vis"> · 🔒</span>}
              </div>
              <div className="de-meta">
                {e.cityName && <span>{e.cityName}</span>}
                <span>{new Date(e.ts).toISOString().slice(0,16).replace('T', ' ')}</span>
                <div className="de-actions">
                  <button className="de-act edit"
                          onClick={() => startEdit(e)}
                          title={D.editTitle}
                          aria-label={D.editTitle}>✎</button>
                  <button className="de-act del"
                          onClick={() => askDelete(e)}
                          title={D.delTitle}
                          aria-label={D.delTitle}>🗑</button>
                </div>
              </div>
              <div className="de-body">{e.body}</div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

/* ===== diary tab inside TravelLog: my entries vs public feed sub-toggle ===== */
function DiaryWithFeed(props) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const L = lang === 'zh' ? { mine:'我的日记', feed:'公开 feed' } : { mine:'My diary', feed:'Public feed' };
  const [view, setView] = useState('mine');
  return (
    <div>
      <div className="diary-subtabs">
        <button type="button"
                className={`ms-tab ${view === 'mine' ? 'active' : ''}`}
                onClick={() => setView('mine')}>✎ {L.mine} ({props.entries?.length || 0})</button>
        <button type="button"
                className={`ms-tab ${view === 'feed' ? 'active' : ''}`}
                onClick={() => setView('feed')}>🌐 {L.feed}</button>
      </div>
      {view === 'mine' ? <Diary {...props} /> : <DiaryFeed/>}
    </div>
  );
}

/* ===== diary public feed · 公开日记 + 点赞 + 评论 (Supabase) ===== */
function DiaryFeed() {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const F = lang === 'zh' ? {
    feed:'公开日记 · Public', empty:'还没有人公开日记。', login:'登录后才能点赞 / 评论。',
    likes:'赞', reply:'评论', send:'发送', placeholder:'写下你的回应…',
    online:'🌐 公网', offline:'💾 本地缓存',
    sec:'秒前', min:'分钟前', hr:'小时前', day:'天前',
    failComment:'评论失败：',
  } : {
    feed:'Public diary feed', empty:'No public entries yet.', login:'Sign in to like / comment.',
    likes:'likes', reply:'Reply', send:T('btn.send'), placeholder:'Write your reply…',
    online:'🌐 Online', offline:'💾 Cached',
    sec:'s ago', min:'m ago', hr:'h ago', day:'d ago',
    failComment:'Comment failed: ',
  };
  const ago = (iso) => {
    if (!iso) return '';
    const m = (Date.now() - new Date(iso).getTime()) / 60000;
    if (m < 1) return '·';
    if (lang === 'zh') {
      if (m < 60) return `${Math.floor(m)} ${F.min}`;
      if (m < 1440) return `${Math.floor(m / 60)} ${F.hr}`;
      return `${Math.floor(m / 1440)} ${F.day}`;
    }
    if (m < 60) return `${Math.floor(m)}${F.min}`;
    if (m < 1440) return `${Math.floor(m / 60)}${F.hr}`;
    return `${Math.floor(m / 1440)}${F.day}`;
  };

  const supa = window.SUPA;
  const auth = window.__conyAuth || {};
  const me = auth.user || null;
  const myProfile = auth.profile || null;
  const [entries, setEntries] = useState([]);
  const [profiles, setProfiles] = useState({}); // userId -> {display_name}
  const [comments, setComments] = useState({}); // entryId -> [{id,user_id,content,created_at}]
  const [likes, setLikes] = useState({});       // entryId -> count
  const [myLikes, setMyLikes] = useState(new Set()); // entryIds I've liked
  const [replyOpen, setReplyOpen] = useState(null);
  const [replyText, setReplyText] = useState('');
  const [online, setOnline] = useState(false);

  const load = async () => {
    if (!supa) return;
    try {
      const { data: rows, error } = await supa
        .from('diary_entries').select('*')
        .eq('visibility', 'public')
        .order('created_at', { ascending: false })
        .limit(40);
      if (error) throw error;
      setOnline(true);
      setEntries(rows || []);
      const ids = (rows || []).map(r => r.id);
      const userIds = Array.from(new Set((rows || []).map(r => r.user_id)));
      // resolve display names
      if (userIds.length) {
        const { data: profs } = await supa.from('profiles').select('id, display_name').in('id', userIds);
        const map = {};
        (profs || []).forEach(p => map[p.id] = p);
        setProfiles(map);
      }
      // fetch comments + likes for these entries (one round trip each)
      if (ids.length) {
        const [{ data: cs }, { data: ls }] = await Promise.all([
          supa.from('diary_comments').select('*').in('entry_id', ids).order('created_at', { ascending: true }),
          supa.from('diary_likes').select('entry_id, user_id').in('entry_id', ids),
        ]);
        const byEntry = {}; (cs || []).forEach(c => { (byEntry[c.entry_id] ||= []).push(c); });
        setComments(byEntry);
        const counts = {}; const mine = new Set();
        (ls || []).forEach(l => {
          counts[l.entry_id] = (counts[l.entry_id] || 0) + 1;
          if (me && l.user_id === me.id) mine.add(l.entry_id);
        });
        setLikes(counts); setMyLikes(mine);
      }
    } catch (_) { setOnline(false); /* silent — RLS / migration not run yet */ }
  };
  useEffect(() => { load(); }, []); // eslint-disable-line

  // realtime: refresh on any insert/delete to entries / comments / likes
  useEffect(() => {
    if (!supa) return;
    const ch = supa.channel('diary-feed')
      .on('postgres_changes', { event: '*', schema: 'public', table: 'diary_entries' }, () => load())
      .on('postgres_changes', { event: '*', schema: 'public', table: 'diary_comments' }, () => load())
      .on('postgres_changes', { event: '*', schema: 'public', table: 'diary_likes' }, () => load())
      .subscribe();
    return () => supa.removeChannel(ch);
  }, []); // eslint-disable-line

  const toggleLike = async (entryId) => {
    if (!me || !supa) return;
    const has = myLikes.has(entryId);
    // optimistic
    setMyLikes(prev => {
      const n = new Set(prev); has ? n.delete(entryId) : n.add(entryId); return n;
    });
    setLikes(prev => ({ ...prev, [entryId]: (prev[entryId] || 0) + (has ? -1 : 1) }));
    try {
      if (has) {
        await supa.from('diary_likes').delete().eq('entry_id', entryId).eq('user_id', me.id);
      } else {
        await supa.from('diary_likes').upsert({ entry_id: entryId, user_id: me.id });
      }
    } catch (_) { /* ignore — realtime subscription will reconcile */ }
  };

  const sendComment = async (entryId) => {
    if (!me || !supa || !replyText.trim()) return;
    const text = replyText.trim();
    setReplyText(''); setReplyOpen(null);
    // optimistic
    const tmp = { id: `tmp-${Date.now()}`, entry_id: entryId, user_id: me.id, content: text, created_at: new Date().toISOString(), _optimistic: true };
    setComments(prev => ({ ...prev, [entryId]: [ ...(prev[entryId] || []), tmp ] }));
    try {
      const { error } = await supa.from('diary_comments').insert({ entry_id: entryId, user_id: me.id, content: text });
      if (error) throw error;
    } catch (e) { (window.conyAlert || window.alert)(F.failComment + (e.message || e)); load(); }
  };

  return (
    <div className="diary-feed">
      <div className="df-head">
        <span className="df-tag">{F.feed}</span>
        <span className={`df-online ${online ? 'on' : 'off'}`}>{online ? F.online : F.offline}</span>
      </div>
      {!me && <div className="df-loginhint">{F.login}</div>}
      {entries.length === 0 ? (
        <div className="empty" style={{ padding:'24px 0' }}>{F.empty}</div>
      ) : (
        <div className="df-list">
          {entries.map(e => {
            const author = profiles[e.user_id] || {};
            const cs = comments[e.id] || [];
            const liked = myLikes.has(e.id);
            const isReplying = replyOpen === e.id;
            return (
              <div key={e.id} className="df-card">
                <div className="df-card-head">
                  <span className="df-avatar">{(author.display_name || '?').charAt(0)}</span>
                  <span className="df-author">{author.display_name || '…'}</span>
                  {e.country_id && <span className="df-flag">{flagOf(e.country_id)}</span>}
                  {e.city_name && <span className="df-city">📍 {e.city_name}</span>}
                  <span className="df-time">{ago(e.created_at)}</span>
                </div>
                <div className="df-title">{e.mood} {e.title}</div>
                <div className="df-body">{e.body}</div>
                <div className="df-actions">
                  <button type="button"
                          className={`df-like ${liked ? 'on' : ''}`}
                          onClick={() => toggleLike(e.id)}
                          disabled={!me}>
                    ♥ {likes[e.id] || 0}
                  </button>
                  <button type="button"
                          className="df-reply-btn"
                          onClick={() => { setReplyOpen(isReplying ? null : e.id); setReplyText(''); }}
                          disabled={!me}>
                    💬 {cs.length}
                  </button>
                </div>
                {cs.length > 0 && (
                  <div className="df-comments">
                    {cs.map(c => {
                      const cAuthor = profiles[c.user_id] || {};
                      return (
                        <div key={c.id} className="df-comment">
                          <span className="df-c-name">{cAuthor.display_name || '…'}</span>
                          <span className="df-c-time">{ago(c.created_at)}</span>
                          <div className="df-c-body">{c.content}</div>
                        </div>
                      );
                    })}
                  </div>
                )}
                {isReplying && (
                  <div className="df-compose">
                    <textarea
                      value={replyText}
                      onChange={ev => setReplyText(ev.target.value)}
                      placeholder={F.placeholder}
                      rows={2}
                      maxLength={300}
                      autoFocus
                    />
                    <div className="df-compose-row">
                      <span className="df-c-count">{replyText.length}/300</span>
                      <button type="button"
                              className="btn primary"
                              onClick={() => sendComment(e.id)}
                              disabled={!replyText.trim()}>{F.send}</button>
                    </div>
                  </div>
                )}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

function SaveFileModal({ onClose }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const S = lang === 'zh' ? {
    copied:'已复制到剪贴板，粘贴到另一台设备的"导入"即可',
    manual:'手动复制下面的存档：',
    badFmt:'存档格式不对', badStruct:'存档结构无效',
    confirmOverwrite:'即将覆盖本机所有 CONY 数据，确认导入？',
    imported:'导入成功，刷新中…',
  } : {
    copied:'Copied to clipboard. Paste into "Import" on another device.',
    manual:'Copy the save manually:',
    badFmt:'Invalid save format', badStruct:'Invalid save structure',
    confirmOverwrite:'This will overwrite all current CONY data. Import anyway?',
    imported:'Import succeeded. Reloading…',
  };
  const [tab, setTab] = useState('export');
  const [imported, setImported] = useState('');

  const collectAll = () => {
    const KEYS = ['cony.balance','cony.tickets','cony.payslips','cony.pet','cony.skin',
      'cony.startCity','cony.startedAt','cony.nickname'];
    const data = { _v:1, _exportedAt: new Date().toISOString(), entries: {} };
    for (const k of Object.keys(localStorage)) {
      if (k.startsWith('cony.')) {
        try { data.entries[k] = JSON.parse(localStorage.getItem(k)); }
        catch { data.entries[k] = localStorage.getItem(k); }
      }
    }
    return data;
  };
  const downloadSave = () => {
    const data = collectAll();
    const blob = new Blob([JSON.stringify(data, null, 2)], { type:'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `cony-save-${new Date().toISOString().slice(0,10)}.json`;
    a.click();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  };
  const copyToClipboard = async () => {
    const data = collectAll();
    try {
      await navigator.clipboard.writeText(JSON.stringify(data));
      (window.conyAlert || window.alert)(S.copied);
    } catch {
      prompt(S.manual, JSON.stringify(data));
    }
  };
  const onFile = (e) => {
    const f = e.target.files?.[0];
    if (!f) return;
    const r = new FileReader();
    r.onload = () => setImported(r.result);
    r.readAsText(f);
  };
  const applyImport = () => {
    if (!imported.trim()) return;
    let data;
    try { data = JSON.parse(imported); } catch { (window.conyAlert || window.alert)(S.badFmt); return; }
    if (!data || !data.entries) { (window.conyAlert || window.alert)(S.badStruct); return; }
    if (!confirm(S.confirmOverwrite)) return;
    for (const [k, v] of Object.entries(data.entries)) {
      try { localStorage.setItem(k, typeof v === 'string' ? v : JSON.stringify(v)); } catch {}
    }
    (window.conyAlert || window.alert)(S.imported);
    location.reload();
  };

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal save-file-modal" onClick={e=>e.stopPropagation()}>
        <div className="modal-head">
          <div className="modal-title">{T('save.title')}</div>
          <button className="modal-close" onClick={onClose} aria-label={T('btn.close')}>×</button>
        </div>
        <div className="modal-summary no-print">
          <div className="ms-tabs">
            <button className={`ms-tab ${tab === 'export' ? 'active' : ''}`}
                    onClick={() => setTab('export')}>{T('btn.export')}</button>
            <button className={`ms-tab ${tab === 'import' ? 'active' : ''}`}
                    onClick={() => setTab('import')}>{T('btn.import')}</button>
          </div>
        </div>
        <div className="modal-body">
          {tab === 'export' ? (
            <>
              <p className="sf-desc">Pack all your data into one JSON file. Move it to another device by importing it back — no account needed.</p>
              <div className="sf-stats">
                <div><span>{T('app.tickets')}</span><strong>{(JSON.parse(localStorage.getItem('cony.tickets')||'[]')).length}</strong></div>
                <div><span>{T('section.jobs')}</span><strong>{(JSON.parse(localStorage.getItem('cony.payslips')||'[]')).length}</strong></div>
                <div><span>{T('app.balance')}</span><strong>${JSON.parse(localStorage.getItem('cony.balance')||'0')}</strong></div>
                <div><span>{T('pet.title')}</span><strong>{JSON.parse(localStorage.getItem('cony.pet')||'null')?.name || '—'}</strong></div>
              </div>
              <div className="sf-actions">
                <button className="btn primary" onClick={downloadSave}>{T('btn.export')}</button>
                <button className="btn ghost" onClick={copyToClipboard}>{T('btn.copy')}</button>
              </div>
            </>
          ) : (
            <>
              <p className="sf-desc">{T('save.warn')}</p>
              <input type="file" accept="application/json,.json" onChange={onFile} className="sf-file"/>
              <textarea
                value={imported}
                onChange={e => setImported(e.target.value)}
                placeholder="…"
                rows={6}
                className="sf-textarea"
              />
              <div className="sf-actions">
                <button className="btn primary" onClick={applyImport} disabled={!imported.trim()}>
                  {T('btn.import')}
                </button>
              </div>
            </>
          )}
        </div>
      </div>
    </div>
  );
}

function TopBar({ currentCity, currentTransport, traveling, onHelp, onLog, onAlbum, onFriends, onUpgrade, onLang, onAuth, profile, user, ticketCount, pet, balance, passportCountry, isPaid, onEndGame }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'zh';
  // pick localized city / transport name based on language
  const cityLabel = currentCity ? (lang === 'zh' ? (currentCity.name_zh || currentCity.name) : (currentCity.name || currentCity.name_zh)) : '—';
  const transportLabel = T(`trans.${currentTransport.id}`);
  const cur = currentCity && window.COUNTRY_CURRENCY?.[currentCity.country];
  const localText = (cur && cur.code !== 'USD' && typeof balance === 'number')
    ? (cur.decimals === 0
        ? `${cur.symbol}${Math.round(balance * cur.perUSD).toLocaleString()}`
        : `${cur.symbol}${(balance * cur.perUSD).toFixed(cur.decimals)}`)
    : null;
  return (
    <div className="topbar">
      <div className="brand">
        <span className="brand-mark"></span>
        <span>Pathfinder</span>
        <span className="brand-kr">{lang === 'zh' ? '文化旅行图' : 'cultural transit'}</span>
        {passportCountry && (() => {
          const tier = window.getCountryTier ? window.getCountryTier(passportCountry) : 3;
          const flag = window.flagFor ? window.flagFor(passportCountry) : '🌐';
          const cname = window.countryLabel ? window.countryLabel(passportCountry) : passportCountry;
          return (
            <span className="passport-pill" title={`${T('visa.passport') || 'Passport'} · ${cname} · T${tier}`}>
              <span className="pp-flag">{flag}</span>
              <span className="pp-code">{passportCountry}</span>
              <span className="pp-tier" data-tier={tier}>T{tier}</span>
            </span>
          );
        })()}
      </div>
      <div className="topbar-right">
        <button className={`auth-pill ${profile ? 'on' : 'off'}`} onClick={onAuth}
                title={profile ? profile.display_name : T('top.signin')}>
          {profile ? (
            <>
              <span className="ap-avatar">{(profile.display_name || '?').charAt(0)}</span>
              <span className="ap-name">{profile.display_name}</span>
            </>
          ) : (
            <>
              <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
                <circle cx="12" cy="9" r="3.5" fill="none" stroke="currentColor" strokeWidth="1.8"/>
                <path d="M5 20c0-3.5 3-6 7-6s7 2.5 7 6" fill="none" stroke="currentColor" strokeWidth="1.8"/>
              </svg>
              <span className="ap-text">{T('top.signin')}</span>
            </>
          )}
        </button>
        {typeof balance === 'number' && (
          <div className="topbar-stat wallet" title="USD">
            <span style={{color: 'var(--ink-3)'}}>{T('app.balance')}</span>
            <span className={`v ${balance < 50 ? 'warn' : ''}`}>${balance.toLocaleString()}</span>
            {localText && <span className="v-local">≈ {localText}</span>}
          </div>
        )}
        <div className="topbar-stat">
          <span style={{color: 'var(--ink-3)'}}>{T('app.now')}</span>
          <span className="v">{cityLabel}</span>
        </div>
        <div className="topbar-stat">
          <span style={{color: 'var(--ink-3)'}}>{T('app.status')}</span>
          <span className={`v ${traveling ? 'warn' : 'ok'}`}>
            {traveling ? T('app.duration') : transportLabel}
          </span>
        </div>
        {/* Friends · real human friends · separate from NPC companion picker.
            If the user isn't signed in we send them to sign-in via onAuth. */}
        <button className="icon-btn friends-btn"
                onClick={() => { if (!user || !profile) onAuth?.(); else onFriends?.(); }}
                title={T('top.friends') || 'Friends'}
                aria-label={T('top.friends') || 'Friends'}>
          <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
            <circle cx="9" cy="8" r="3" fill="none" stroke="currentColor" strokeWidth="1.6"/>
            <circle cx="16" cy="9" r="2.5" fill="none" stroke="currentColor" strokeWidth="1.6"/>
            <path d="M3 19c0-3.5 2.5-5 6-5s6 1.5 6 5" fill="none" stroke="currentColor" strokeWidth="1.6"/>
            <path d="M14 19c0-2 1.6-3.5 4-3.5s3 1.5 3 3.5" fill="none" stroke="currentColor" strokeWidth="1.4"/>
          </svg>
        </button>
        {/* NPC companion button removed — real-friend invites still happen
            inside the DepartConfirm modal. */}
        {/* Upgrade — only shown to free-tier players. Big pulsing pill with
            the price right on it, so a single tap is enough to start payment. */}
        {!isPaid && onUpgrade && (() => {
          const label = lang === 'zh' ? '解锁全部'
                      : lang === 'ko' ? '잠금 해제'
                      : lang === 'ja' ? 'すべて解除'
                      : 'Unlock';
          return (
            <button className="topbar-upgrade-btn pulse" type="button" onClick={onUpgrade}>
              <span className="tu-spark" aria-hidden="true">✨</span>
              <span className="tu-label">{label}</span>
              <span className="tu-price">$2.99</span>
            </button>
          );
        })()}
        <button className="icon-btn album-btn" onClick={onAlbum} title={T('top.album')} aria-label={T('top.album')}>
          <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
            <rect x="3" y="5" width="18" height="14" rx="2" fill="none" stroke="currentColor" strokeWidth="1.6"/>
            <circle cx="8.5" cy="10" r="1.5" fill="currentColor"/>
            <path d="M21 17l-5-5-4 4-3-3-6 6" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round"/>
          </svg>
        </button>
        {/* save-btn + pet-btn removed — manual save unnecessary (auto-localStorage), pet system cut */}
        <button className="icon-btn log-btn" onClick={onLog} title={T('top.log')}>
          <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
            <path d="M5 4h10l4 4v12H5z" fill="none" stroke="currentColor" strokeWidth="1.6"/>
            <path d="M15 4v4h4" fill="none" stroke="currentColor" strokeWidth="1.6"/>
            <path d="M8 12h8 M8 15h8 M8 18h5" stroke="currentColor" strokeWidth="1.4"/>
          </svg>
          {ticketCount > 0 && <span className="ib-badge">{ticketCount}</span>}
        </button>
        {onEndGame && (
          <button className="icon-btn end-game-btn" onClick={onEndGame} title={T('top.endGame') || 'End run'} aria-label="End run">
            <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
              <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.6"/>
              <path d="M8 8l8 8 M16 8l-8 8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
            </svg>
          </button>
        )}
        <button className="icon-btn lang-btn" onClick={onLang} title={T('top.lang')} aria-label={T('top.lang')}>
          <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
            <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.6"/>
            <path d="M3 12h18 M12 3a14 14 0 0 1 0 18 M12 3a14 14 0 0 0 0 18" fill="none" stroke="currentColor" strokeWidth="1.2"/>
          </svg>
        </button>
        <button className="icon-btn" onClick={onHelp} title={T('top.help')}>?</button>
      </div>
    </div>
  );
}

/* ================= transport bar ================= */
function TransportBar({ active, onSelect, from, to }) {
  const T = (window.useT ? window.useT() : (k) => k);
  return (
    <div className="transport-bar">
      {window.TRANSPORTS.map(t => {
        const check = to ? window.canTravel(from, to, t.id) : { ok: true };
        const disabled = !check.ok;
        return (
          <div
            key={t.id}
            className={`transport-option ${active === t.id ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
            onClick={() => { if (!disabled) onSelect(t.id); }}
            title={disabled ? check.reason : ''}
          >
            <div className="transport-icon"><TransportIcon id={t.id} /></div>
            <div>
              <div className="transport-name">{T(`trans.${t.id}`)}</div>
              <div className="transport-meta">{disabled ? check.reason : t.meta}</div>
            </div>
          </div>
        );
      })}
    </div>
  );
}

/* ================= city node ================= */
function CityNode({ city, state, visited, onClick, onMemoryClick }) {
  const lang = window.getLang ? window.getLang() : 'en';
  const T = (window.useT ? window.useT() : (k) => k);
  const { x, y } = window.PROJECT(city.lat, city.lon);
  // English-first: zh mode shows name_zh; everything else shows English name
  const labelName = lang === 'zh' ? (city.name_zh || city.name) : (city.name || city.name_zh);
  const labelMeta = lang === 'zh' ? `${city.name} · ${city.tz}` : `${city.tz}`;
  return (
      <div
        className={`city ${state} ${visited ? 'visited' : ''}`}
        style={{ left: `${x}%`, top: `${y}%` }}
        onClick={onClick}
      >
        <div className="city-pulse"></div>
        <div className="city-dot"></div>
        {visited && (
          <button
            className="city-flag"
            type="button"
            title={`${T('top.diary')} / ${T('top.album')}`}
            onClick={(e) => { e.stopPropagation(); onMemoryClick?.(city); }}
          >{flagOf(city.country)}</button>
        )}
        <div className="city-label">
          <span className="label-name">{labelName}</span>
          <span className="label-meta">{labelMeta}</span>
        </div>
      </div>
  );
}

/* ===== city memory popup · 点击地图国旗后展示该城日记 + 照片 ===== */
function CityMemoryModal({ city, diary, photos, onClose }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  if (!city) return null;
  const cityNameZh = city.name_zh || city.name;
  const cityName = lang === 'zh' ? cityNameZh : (city.name || city.name_zh);
  const M = lang === 'zh' ? {
    sub:'旅人印记', count: (d, p) => `${d} 篇日记 · ${p} 张照片`,
    empty: (n) => <>还没在 {n} 留下任何印记。<br/>下次到这里写一条日记 / 拍一张照片，会出现在这里。</>,
    photos:'📷 照片', diaries:'✎ 日记',
  } : {
    sub:'Travel notes', count: (d, p) => `${d} entries · ${p} photos`,
    empty: (n) => <>No entries in {n} yet.<br/>Next time, leave a diary or a photo here.</>,
    photos:'📷 Photos', diaries:'✎ Diary',
  };
  const myDiaries = diary.filter(e =>
    e.cityName === cityNameZh ||
    e.cityName === city.name ||
    e.countryId === city.country
  );
  const myPhotos = photos.filter(p =>
    p.cityName === cityNameZh ||
    p.cityName === city.name
  );
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal city-memory" onClick={e => e.stopPropagation()}>
        <div className="modal-head">
          <div className="modal-title">
            <span className="cm-flag">{flagOf(city.country)}</span>
            {cityName} · {M.sub}
          </div>
          <button className="modal-close" onClick={onClose}>×</button>
        </div>
        <div className="modal-summary">
          {M.count(myDiaries.length, myPhotos.length)}
        </div>
        <div className="modal-body">
          {myDiaries.length === 0 && myPhotos.length === 0 && (
            <div className="empty">{M.empty(cityName)}</div>
          )}
          {myPhotos.length > 0 && (
            <>
              <div className="cm-section-title">{M.photos}</div>
              <div className="album-grid">
                {myPhotos.map(p => (
                  <div key={p.id} className="album-photo">
                    <img src={p.image} alt={p.caption}/>
                    <div className="ap-meta">
                      <div className="ap-cap">{p.caption || '·'}</div>
                      <div className="ap-date">{new Date(p.ts).toISOString().slice(0,10)}</div>
                    </div>
                  </div>
                ))}
              </div>
            </>
          )}
          {myDiaries.length > 0 && (
            <>
              <div className="cm-section-title">{M.diaries}</div>
              <div className="cm-diary-list">
                {myDiaries.map(e => (
                  <div key={e.id} className="cm-diary-entry">
                    <div className="de-title"><span className="de-mood">{e.mood}</span> {e.title}</div>
                    <div className="de-meta">
                      <span>{new Date(e.ts).toISOString().slice(0,16).replace('T', ' ')}</span>
                    </div>
                    <div className="de-body">{e.body}</div>
                  </div>
                ))}
              </div>
            </>
          )}
        </div>
      </div>
    </div>
  );
}

/* ================= route SVG ================= */
function RoutePath({ from, to, transport, animateKey }) {
  if (!from || !to) return null;
  const a = window.PROJECT(from.lat, from.lon);
  const b = window.PROJECT(to.lat, to.lon);
  const t = window.TRANSPORTS.find(x => x.id === transport);
  const cx = (a.x + b.x) / 2;
  const cy = (a.y + b.y) / 2 - (t?.curve || 0) * 30;

  let d;
  if (transport === 'subway') {
    d = `M${a.x} ${a.y} L${b.x} ${b.y}`;
  } else if (transport === 'bus') {
    d = `M${a.x} ${a.y} L${cx} ${a.y} L${cx} ${b.y} L${b.x} ${b.y}`;
  } else if (transport === 'ship') {
    // gentle wavy arc using two control points
    const dx = b.x - a.x, dy = b.y - a.y;
    const c1x = a.x + dx * 0.33, c1y = (a.y + b.y) / 2 - (t?.curve || 0) * 30;
    const c2x = a.x + dx * 0.66, c2y = (a.y + b.y) / 2 + (t?.curve || 0) * 30;
    d = `M${a.x} ${a.y} C${c1x} ${c1y} ${c2x} ${c2y} ${b.x} ${b.y}`;
  } else {
    d = `M${a.x} ${a.y} Q${cx} ${cy} ${b.x} ${b.y}`;
  }
  return (
    <svg className="map-svg" viewBox="0 0 100 100" preserveAspectRatio="none" key={animateKey}>
      <path className={`route-path ${transport} animate`} d={d} vectorEffect="non-scaling-stroke" />
    </svg>
  );
}

/* ================= traveling vehicle ================= */
function VehicleOnRoute({ from, to, transport, progress }) {
  if (!from || !to) return null;
  const a = window.PROJECT(from.lat, from.lon);
  const b = window.PROJECT(to.lat, to.lon);
  const t = window.TRANSPORTS.find(x => x.id === transport);
  const cx = (a.x + b.x) / 2;
  const cy = (a.y + b.y) / 2 - (t?.curve || 0) * 30;

  let x, y;
  const u = progress;
  if (transport === 'subway') {
    x = a.x + (b.x - a.x) * u;
    y = a.y + (b.y - a.y) * u;
  } else if (transport === 'bus') {
    if (u < 0.33) { const k = u/0.33; x = a.x + (cx-a.x)*k; y = a.y; }
    else if (u < 0.66) { const k = (u-0.33)/0.33; x = cx; y = a.y + (b.y-a.y)*k; }
    else { const k = (u-0.66)/0.34; x = cx + (b.x-cx)*k; y = b.y; }
  } else if (transport === 'ship') {
    const dx = b.x - a.x;
    const c1x = a.x + dx * 0.33, c1y = (a.y + b.y) / 2 - (t?.curve || 0) * 30;
    const c2x = a.x + dx * 0.66, c2y = (a.y + b.y) / 2 + (t?.curve || 0) * 30;
    const om = 1 - u;
    x = om*om*om*a.x + 3*om*om*u*c1x + 3*om*u*u*c2x + u*u*u*b.x;
    y = om*om*om*a.y + 3*om*om*u*c1y + 3*om*u*u*c2y + u*u*u*b.y;
  } else {
    const om = 1 - u;
    x = om*om*a.x + 2*om*u*cx + u*u*b.x;
    y = om*om*a.y + 2*om*u*cy + u*u*b.y;
  }
  const prev = Math.max(0, u - 0.02);
  let px, py;
  if (transport === 'subway') {
    px = a.x + (b.x - a.x) * prev; py = a.y + (b.y - a.y) * prev;
  } else if (transport === 'bus') {
    px = x - 1; py = y;
  } else if (transport === 'ship') {
    const dx = b.x - a.x;
    const c1x = a.x + dx * 0.33, c1y = (a.y + b.y) / 2 - (t?.curve || 0) * 30;
    const c2x = a.x + dx * 0.66, c2y = (a.y + b.y) / 2 + (t?.curve || 0) * 30;
    const om = 1 - prev;
    px = om*om*om*a.x + 3*om*om*prev*c1x + 3*om*prev*prev*c2x + prev*prev*prev*b.x;
    py = om*om*om*a.y + 3*om*om*prev*c1y + 3*om*prev*prev*c2y + prev*prev*prev*b.y;
  } else {
    const om = 1 - prev;
    px = om*om*a.x + 2*om*prev*cx + prev*prev*b.x;
    py = om*om*a.y + 2*om*prev*cy + prev*prev*b.y;
  }
  const angle = Math.atan2(y - py, x - px) * 180 / Math.PI;
  return (
    <div className={`vehicle-trail ${transport}`} style={{ left: `${x}%`, top: `${y}%` }}>
      <VehicleGlyph id={transport} angle={angle} />
    </div>
  );
}

/* ================= travel overlay ================= */
const SPEED_KMH = { plane: 900, subway: 350, bus: 90, ship: 40 };

function haversineKm(a, b) {
  const R = 6371;
  const toRad = (d) => d * Math.PI / 180;
  const dLat = toRad(b.lat - a.lat);
  const dLon = toRad(b.lon - a.lon);
  const la1 = toRad(a.lat), la2 = toRad(b.lat);
  const x = Math.sin(dLat/2)**2 + Math.cos(la1)*Math.cos(la2)*Math.sin(dLon/2)**2;
  return 2 * R * Math.asin(Math.sqrt(x));
}

function formatDuration(mins) {
  const lang = (window.getLang ? window.getLang() : 'en');
  const U = lang === 'zh'
    ? { now:'抵达', m:' 分钟', h:' 小时', mShort:' 分', d:' 天' }
    : { now:'arriving', m:'m', h:'h', mShort:'m', d:'d' };
  if (mins < 1) return U.now;
  if (mins < 60) return `${Math.round(mins)}${U.m}`;
  const h = Math.floor(mins / 60);
  const m = Math.round(mins % 60);
  if (h < 24) return m > 0 ? `${h}${U.h} ${m}${U.mShort}` : `${h}${U.h}`;
  const d = Math.floor(h / 24);
  const rh = h % 24;
  return rh > 0 ? `${d}${U.d} ${rh}${U.h}` : `${d}${U.d}`;
}

function TravelOverlay({ from, to, transport, progress, cabinClass, onSkip, onUpgrade, onCancel, balance }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  if (!from || !to) return null;
  const t = window.TRANSPORTS.find(x => x.id === transport);
  const distance = haversineKm(from, to);
  const routeMul = transport === 'bus' ? 1.4 : transport === 'ship' ? 1.25 : 1.0;
  const realKm = distance * routeMul;
  const cabin = (window.CABIN_CLASSES?.[transport] || []).find(c => c.id === cabinClass);
  // Real-world minutes, factoring cabin timeMult (Economy 1.0 / Business 0.5 / First 0.1)
  const cabinTime = cabin?.timeMult ?? 1;
  const totalMin = (realKm / SPEED_KMH[transport]) * 60 * cabinTime;
  const remainMin = (1 - progress) * totalMin;
  const cabinLabel = cabin ? (lang === 'zh' ? cabin.name_zh : (cabin.name || cabin.name_zh)) : '';
  const isPremium = cabinClass === 'first' || cabinClass === 'business';
  const cityLbl = (c) => lang === 'zh' ? (c.name_zh || c.name) : (c.name || c.name_zh);
  const remainLabel = lang === 'zh'
    ? `${T('flight.remain')} ${formatDuration(remainMin)} · ${Math.round(progress * 100)}%`
    : `${formatDuration(remainMin)} ${T('flight.remain')} · ${Math.round(progress * 100)}%`;
  // boarding-pass style codes
  const code3 = (city) => {
    const n = (city.name || city.name_zh || '').replace(/[^A-Za-z]/g,'').toUpperCase();
    return n.slice(0, 3) || (city.id || '???').slice(0,3).toUpperCase();
  };
  const fromCode = code3(from);
  const toCode = code3(to);
  const flightNo = `${(transport.slice(0,2) || 'XX').toUpperCase()}${Math.abs([...from.id+to.id].reduce((a,c)=>(a*31+c.charCodeAt(0))|0,0)) % 9000 + 1000}`;
  return (
    <div className={`travel-overlay tv-${transport} cabin-${cabinClass || 'economy'} ${isPremium ? 'premium' : 'economy'}`}>
      <CabinScene transport={transport} cabinClass={cabinClass} progress={progress}/>
      <div className="travel-card v2">
        <div className="tv-header">
          <span className="tv-tag">{T(`trans.${transport}`)} · {flightNo}</span>
          {cabinLabel && <span className="tv-class">{cabinLabel}</span>}
        </div>
        <div className="tv-route-row">
          <div className="tv-side">
            <div className="tv-code">{fromCode}</div>
            <div className="tv-city">{cityLbl(from)}</div>
          </div>
          <div className="tv-mid">
            <div className="tv-km">{Math.round(realKm).toLocaleString()} KM</div>
            <div className="tv-line">
              <span className="tv-pin"/>
              <span className="tv-track"/>
              <span className="tv-vehicle"
                    style={{ left: `${progress * 100}%` }}>
                {transport === 'plane' ? '✈' : transport === 'ship' ? '⛴' : transport === 'subway' ? '🚄' : '🚌'}
              </span>
              <span className="tv-pin end"/>
            </div>
            <div className="tv-eta">{remainLabel}</div>
          </div>
          <div className="tv-side">
            <div className="tv-code">{toCode}</div>
            <div className="tv-city">{cityLbl(to)}</div>
          </div>
        </div>
        <div className="travel-action-row">
          {onUpgrade && progress < 0.95 && (() => {
            const cabins = (window.CABIN_CLASSES?.[transport] || []);
            const curIdx = cabins.findIndex(c => c.id === cabinClass);
            const upgrades = cabins.filter((c, i) => i > curIdx && (c.timeMult ?? 1) < (cabins[curIdx]?.timeMult ?? 1));
            if (upgrades.length === 0) return null;
            return (
              <button className="travel-upgrade-btn" type="button" onClick={() => onUpgrade(upgrades, cabin)}>
                ⏵ {T('cabin.upgrade')}
              </button>
            );
          })()}
          {onCancel && progress < 0.95 && (() => {
            const fromName = cityLbl(from);
            const msg = lang === 'zh'
              ? `中止此次飞行，返回 ${fromName}？\n（机票钱还没扣，行程会被取消。）`
              : lang === 'ko'
                ? `이 비행을 중단하고 ${fromName}(으)로 돌아가시겠어요?\n(아직 요금은 빠지지 않았고 일정만 취소됩니다.)`
                : `Cancel this flight and return to ${fromName}?\n(No fare has been charged yet — the trip will just be aborted.)`;
            const label = lang === 'zh' ? '中止 · 返回'
              : lang === 'ko' ? '비행 중단 · 돌아가기'
              : lang === 'ja' ? 'フライト中止'
              : 'Cancel · Return';
            return (
              <button
                className="travel-cancel-btn"
                type="button"
                onClick={() => {
                  const cf = window.conyConfirm || ((m) => Promise.resolve(window.confirm(m)));
                  cf(msg, { tone: 'warn' }).then(ok => { if (ok) onCancel(); });
                }}
              >
                ↩ {label}
              </button>
            );
          })()}
        </div>
        <window.AmbientSoundPicker transport={transport}/>
      </div>
    </div>
  );
}

/* ================= cabin scene (immersive interior during travel) ================= */
function CabinBanner({ tier, label, sub }) {
  return (
    <div className={`cabin-banner tier-${tier}`}>
      <span className="cb-label">{label}</span>
      {sub && <span className="cb-sub">· {sub}</span>}
    </div>
  );
}
function ChampagneGlass() {
  return (
    <div className="champagne-glass">
      <div className="cg-bubbles">
        {Array.from({length:6}).map((_, i) =>
          <span key={i} className="cg-bubble" style={{ left: `${10 + i * 12}%`, animationDelay: `${i * 0.3}s` }}/>
        )}
      </div>
      <div className="cg-stem"></div>
    </div>
  );
}
function CabinScene({ transport, cabinClass, progress }) {
  // sub-class progress drives parallax depth, but CSS owns the loop animation
  const isFirst   = cabinClass === 'first';
  const isBiz     = cabinClass === 'business';
  const isPremium = isFirst || isBiz;
  const tier = isFirst ? 'first' : isBiz ? 'business' : 'economy';

  if (transport === 'plane') {
    return (
      <div className={`cabin plane ${isPremium ? 'premium' : 'economy'} cabin-anim-${isPremium ? 'float' : 'turb'}`}>
        {isPremium && <div className="ambient-glow"/>}
        <CabinBanner tier={tier}
          label={isFirst ? 'FIRST CLASS' : isBiz ? 'BUSINESS' : 'ECONOMY'}
          sub={isFirst ? '1A · Suite' : isBiz ? '2C · Recliner' : '37B · Middle seat'}/>
        <div className="cabin-roof">
          <div className="roof-light"/><div className="roof-light"/><div className="roof-light"/>
          <div className="roof-light"/><div className="roof-light"/><div className="roof-light"/>
        </div>
        <div className="overhead-bin">
          <span className="bin-no">23-25</span>
          <span className="bin-no">26-28</span>
          <span className="bin-no">29-31</span>
        </div>
        <div className={`cabin-window plane-window ${isPremium ? 'big' : ''}`}>
          <div className="window-scene clouds layer-1"/>
          <div className="window-scene clouds layer-2"/>
          {isPremium && <div className="window-scene clouds layer-3"/>}
          <div className="window-frame"/>
        </div>
        <div className="cabin-interior">
          {isPremium ? (
            <>
              <div className="suite-divider"/>
              <div className="seat lay-flat">
                <div className="seat-blanket"></div>
                <div className="seat-pillow"></div>
                <div className="seat-tv">▶ The Legend of 1900</div>
                <div className="reading-lamp"/>
              </div>
              <div className="tray-side">
                <ChampagneGlass/>
                <div className="meal" aria-label="meal">🍱</div>
                <div className="slippers">👟</div>
              </div>
              <div className="cabin-curtain" aria-hidden="true"></div>
            </>
          ) : (
            <>
              <div className="aisle-strip"/>
              <div className="seat-row">
                <div className="seat economy"><div className="seat-head"/></div>
                <div className="seat economy you">
                  <div className="seat-head"/>
                  <div className="seat-tray"><span className="snack">🥨</span></div>
                </div>
                <div className="seat economy"><div className="seat-head"/></div>
              </div>
              <div className="seat-row back">
                <div className="seat economy"><div className="seat-head"/></div>
                <div className="seat economy"><div className="seat-head"/></div>
                <div className="seat economy"><div className="seat-head"/></div>
              </div>
              <div className="seat-row back back-2">
                <div className="seat economy"><div className="seat-head"/></div>
                <div className="seat economy"><div className="seat-head"/></div>
                <div className="seat economy"><div className="seat-head"/></div>
              </div>
              <div className="seatbelt-sign">⚠ FASTEN SEATBELT</div>
            </>
          )}
        </div>
      </div>
    );
  }

  if (transport === 'ship') {
    // Map unified ids to legacy ship visuals: economy→inside, business→sea, first→suite
    const isInside = cabinClass === 'economy';
    const isSea    = cabinClass === 'business';
    const isSuite  = cabinClass === 'first';
    return (
      <div className={`cabin ship ${cabinClass} cabin-anim-${isInside ? 'inside' : 'rock'}`}>
        <div className="ship-wood-wall"/>
        <div className="ship-lifebuoy">⛟</div>
        {!isInside && (
          <div className={`cabin-window ${isSuite ? 'big-window' : 'porthole'}`}>
            <div className="window-scene ocean layer-1"/>
            <div className="window-scene ocean layer-2"/>
            {isSuite && <div className="sun"/>}
            <div className="porthole-rim"/>
          </div>
        )}
        <CabinBanner tier={tier}
          label={isSuite ? 'SUITE' : isSea ? 'SEA VIEW' : 'INSIDE'}
          sub={isSuite ? 'Balcony · King Bed' : isSea ? 'Balcony · Queen Bed' : 'Windowless · Bunk'}/>
        <div className="cabin-interior">
          {isSuite ? (
            <>
              <div className="suite-bed">
                <div className="bed-pillow"/><div className="bed-pillow"/>
                <div className="bed-blanket"/>
              </div>
              <div className="suite-table">
                <span className="suite-tray">🥂</span>
                <span className="suite-tray">🍓</span>
              </div>
              <div className="suite-rug"/>
            </>
          ) : isSea ? (
            <>
              <div className="bunk sea">
                <div className="bed-pillow"/>
                <div className="bed-blanket"/>
              </div>
              <div className="balcony-rail"/>
            </>
          ) : (
            <>
              <div className="cabin-bulb"/>
              <div className="bunk-stack">
                <div className="bunk small"><div className="bed-pillow"/></div>
                <div className="bunk small you"><div className="bed-pillow"/></div>
              </div>
              <div className="cabin-portshut" aria-hidden="true"></div>
            </>
          )}
        </div>
      </div>
    );
  }

  if (transport === 'bus') {
    return (
      <div className="cabin bus cabin-anim-bumpy">
        <CabinBanner tier="economy" label="COACH" sub="2-2 / 4 rows"/>
        <div className="bus-front">
          <div className="bus-windshield">
            <div className="window-scene highway layer-1"/>
            <div className="window-scene highway layer-2"/>
            <div className="bus-mirror left"/>
            <div className="bus-mirror right"/>
          </div>
          <div className="bus-driver" aria-label="driver">
            <div className="driver-head"/>
            <div className="driver-shoulders"/>
          </div>
          <div className="bus-wheel" aria-label="steering wheel"/>
        </div>
        <div className="cabin-window bus-window side">
          <div className="window-scene highway layer-1"/>
          <div className="window-scene highway layer-2"/>
        </div>
        <div className="cabin-interior">
          <div className="aisle-strip"/>
          <div className="seat-row">
            <div className="seat bus-seat"><div className="seat-head"/></div>
            <div className="aisle"></div>
            <div className="seat bus-seat you"><div className="seat-head"/></div>
          </div>
          <div className="seat-row back">
            <div className="seat bus-seat"><div className="seat-head"/></div>
            <div className="aisle"></div>
            <div className="seat bus-seat"><div className="seat-head"/></div>
          </div>
        </div>
      </div>
    );
  }

  // subway / 高铁
  return (
    <div className={`cabin train ${cabinClass} cabin-anim-${isFirst ? 'glide' : 'glide-fast'}`}>
      {isFirst && <div className="ambient-glow soft"/>}
      <CabinBanner tier={isFirst ? 'first' : 'economy'}
        label={isFirst ? 'FIRST CLASS' : 'STANDARD'}
        sub={isFirst ? '2+1 wide seat + table' : '3+2 standard'}/>
      <div className="cabin-window train-window">
        <div className="window-scene mountains layer-1"/>
        <div className="window-scene mountains layer-2"/>
      </div>
      <div className="cabin-interior">
        {isFirst ? (
          <>
            <div className="seat-row train-row premium">
              <div className="seat train-seat wide"></div>
              <div className="aisle"></div>
              <div className="seat train-seat wide you">
                <div className="seat-tray"><span>☕</span></div>
              </div>
            </div>
            <div className="seat-row train-row premium back">
              <div className="seat train-seat wide"></div>
              <div className="aisle"></div>
              <div className="seat train-seat wide"></div>
            </div>
          </>
        ) : (
          <>
            <div className="seat-row train-row">
              <div className="seat train-seat"></div>
              <div className="seat train-seat"></div>
              <div className="aisle"></div>
              <div className="seat train-seat"></div>
              <div className="seat train-seat you"></div>
            </div>
            <div className="seat-row train-row back">
              <div className="seat train-seat"></div>
              <div className="seat train-seat"></div>
              <div className="aisle"></div>
              <div className="seat train-seat"></div>
              <div className="seat train-seat"></div>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

/* ================= media card (song preview + book quote + film/food → google) ================= */
function MediaCard({ m }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const [detailOpen, setDetailOpen] = useState(false);
  // localized lookup: lang-specific dict → English fallback → original
  const showTitle  = (window.localizeMediaTitle ? window.localizeMediaTitle(m.title) : m.title);
  const showCredit = (window.localizeMediaTitle ? window.localizeMediaTitle(m.credit) : m.credit);

  // Tap → open full detail modal with Wikipedia extract
  const onCardClick = (e) => {
    e.stopPropagation();
    setDetailOpen(true);
    window.dispatchEvent(new CustomEvent('cony-media-tap', { detail: { type: m.type } }));
  };

  return (
    <>
      <div className="media-card tap"
           onClick={onCardClick}>
        <div className="media-row">
          <div className={`media-type-tag ${m.type}`}>{m.type}</div>
          <div className="media-body">
            <div className="media-title">{showTitle}</div>
            <div className="media-credit">{showCredit}</div>
          </div>
          {m.year && <div className="media-year">{m.year}</div>}
          <span className="media-detail-cue" aria-hidden="true">›</span>
        </div>
      </div>
      {detailOpen && (
        <MediaDetailModal m={m} onClose={() => setDetailOpen(false)} />
      )}
    </>
  );
}

/* ================= media detail modal (rich book / film / song / food info) ================= */
function MediaDetailModal({ m, onClose }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const enTitle  = lang !== 'zh' && window.MEDIA_I18N_EN ? window.MEDIA_I18N_EN[m.title]  : null;
  const enCredit = lang !== 'zh' && window.MEDIA_I18N_EN ? window.MEDIA_I18N_EN[m.credit] : null;
  const showTitle  = (window.localizeMediaTitle ? window.localizeMediaTitle(m.title) : (lang === 'zh' ? m.title : (enTitle || m.title)));
  const showCredit = (window.localizeMediaTitle ? window.localizeMediaTitle(m.credit) : (lang === 'zh' ? m.credit : (enCredit || m.credit)));
  const isSong = m.type === 'SONG' || m.type === 'MUSICAL';

  const [wiki, setWiki] = useState(null);   // { extract, thumbnail }
  const [wikiLoading, setWikiLoading] = useState(true);
  const [wikiTried, setWikiTried] = useState(false);

  // Fetch Wikipedia summary in player language, fall back through ja/zh/en
  useEffect(() => {
    let alive = true;
    // Wikipedia has separate language editions. Strategy:
    //   1. Try native lang directly with each candidate title
    //   2. If fail, ask en.wikipedia for langlinks → that's how we find the target-lang title
    //   3. If still nothing, fall back to en.wikipedia content (last resort)
    // Languages that have substantial Wikipedia editions (most of the 36 are covered)
    const titles = [m.title, enTitle, enCredit, m.credit].filter(Boolean);
    const fetchSummary = async (wl, title) => {
      try {
        const r = await fetch(
          `https://${wl}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`,
          { redirect: 'follow' }
        );
        if (!r.ok) return null;
        const d = await r.json();
        if (!d || !d.extract || d.type === 'disambiguation') return null;
        return {
          extract: d.extract,
          thumbnail: d.thumbnail?.source || null,
          source: `https://${wl}.wikipedia.org/wiki/${encodeURIComponent(d.title)}`,
          wikiLang: wl,
          wikiTitle: d.title,
        };
      } catch { return null; }
    };
    const findInTargetLang = async (targetLang, title) => {
      // ask en.wikipedia for the langlink to targetLang
      try {
        const r = await fetch(
          `https://en.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&redirects=1&prop=langlinks&lllang=${targetLang}&lllimit=1&format=json&origin=*`
        );
        if (!r.ok) return null;
        const data = await r.json();
        const pages = data?.query?.pages;
        if (!pages) return null;
        const page = Object.values(pages)[0];
        const link = page?.langlinks?.[0]?.['*'];
        if (!link) return null;
        return await fetchSummary(targetLang, link);
      } catch { return null; }
    };

    async function tryFetch() {
      // Step 1 — direct on target lang
      if (lang !== 'en') {
        for (const t of titles) {
          if (!alive) return false;
          const result = await fetchSummary(lang, t);
          if (result) { setWiki(result); return true; }
        }
      }
      // Step 2 — langlink lookup via en.wikipedia (gives us the target-lang title)
      if (lang !== 'en') {
        for (const t of titles) {
          if (!alive) return false;
          const result = await findInTargetLang(lang, t);
          if (result) { setWiki(result); return true; }
        }
      }
      // Step 3 — English fallback (last resort)
      for (const t of titles) {
        if (!alive) return false;
        const result = await fetchSummary('en', t);
        if (result) { setWiki(result); return true; }
      }
      return false;
    }
    setWikiLoading(true);
    tryFetch().finally(() => { if (alive) { setWikiLoading(false); setWikiTried(true); } });
    return () => { alive = false; };
  // eslint-disable-next-line
  }, [m.title]);

  const ytQuery = encodeURIComponent(`${showTitle} ${showCredit || ''}`.trim());

  const openExternal = () => {
    let url = wiki?.source;
    if (!url) {
      if (isSong) url = `https://www.youtube.com/results?search_query=${ytQuery}`;
      else url = `https://www.google.com/search?q=${ytQuery}`;
    }
    window.open(url, '_blank', 'noopener');
  };

  const typeLabel   = T('mtype.' + m.type) || m.type;
  const creditLabel = T('mcredit.' + m.type) || T('mcredit.default');

  return (
    <div className="modal-overlay media-detail-overlay" onClick={onClose}>
      <div className="modal media-detail-modal" onClick={e => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose} aria-label="close">×</button>
        <div className="mdm-head">
          <div className={`mdm-type-pill ${m.type}`}>{typeLabel}</div>
          {m.year && <div className="mdm-year">· {m.year}</div>}
        </div>
        <div className="mdm-title">{showTitle}</div>
        <div className="mdm-credit-row">
          <span className="mdm-credit-label">{creditLabel}</span>
          <span className="mdm-credit-val">{showCredit || '—'}</span>
        </div>

        {wiki?.thumbnail && (
          <div className="mdm-poster-wrap">
            <img src={wiki.thumbnail} className="mdm-poster" alt="" loading="lazy"/>
          </div>
        )}

        <div className="mdm-body">
          {(() => {
            // Quote and summary are stored in Chinese. Display rule:
            //   - zh user: always show
            //   - ja user: show only if text contains hiragana/katakana (i.e. real Japanese,
            //     not Chinese-only kanji which Japanese readers can decode but feels foreign)
            //   - other languages: show only if text has zero CJK characters (Latin script)
            const hasCJK = (s) => s && /[一-鿿㐀-䶿]/.test(s);
            const hasJpKana = (s) => s && /[぀-ゟ゠-ヿ]/.test(s);
            const safeForLang = (s) => {
              if (!s) return false;
              if (lang === 'zh') return true;
              if (lang === 'ja') return hasJpKana(s) || !hasCJK(s);
              return !hasCJK(s);
            };
            return (
              <>
                {m.quote && safeForLang(m.quote) && (
                  <blockquote className="mdm-quote">
                    <span className="mdm-qmark">「</span>
                    <span className="mdm-qbody">{m.quote}</span>
                    <span className="mdm-qmark right">」</span>
                  </blockquote>
                )}
                {m.summary && safeForLang(m.summary) && (
                  <p className="mdm-summary">{m.summary}</p>
                )}
              </>
            );
          })()}

          {isSong && (
            <div className="mdm-song">
              <SongPreview seed={m.title} title={showTitle} credit={showCredit} youtube={m.youtube}/>
            </div>
          )}

          {wikiLoading && (
            <div className="mdm-wiki-loading">{T('mdm.loading') || '...'}</div>
          )}
          {!wikiLoading && (() => {
            // Strict language match — never show mixed-language Wikipedia.
            const hasExtract = wiki?.extract;
            const isExactMatch = hasExtract && wiki.wikiLang === lang;
            const isEnUserSeeingEn = lang === 'en' && wiki?.wikiLang === 'en';
            if (hasExtract && (isExactMatch || isEnUserSeeingEn)) {
              return (
                <div className="mdm-wiki">
                  <div className="mdm-wiki-label">{T('mdm.wiki') || 'Wikipedia'}</div>
                  <p className="mdm-wiki-text">{wiki.extract}</p>
                </div>
              );
            }
            // No content available in the player's language → show a polite hint
            // so the modal doesn't look broken / stuck.
            if (wikiTried) {
              return (
                <div className="mdm-wiki-empty">{T('mdm.nowiki') || 'No additional info available in your language.'}</div>
              );
            }
            return null;
          })()}
        </div>

        {/* Culture-diary CTA — every 300+ char entry pays a $1000 stipend.
            Closes this modal then fires a window event so the writing modal
            lives at the top of the app tree (avoids deep prop drilling). */}
        {(() => {
          const label = lang === 'zh' ? '✍️ 写文化心得'
            : lang === 'ko' ? '✍️ 문화 감상 쓰기'
            : lang === 'ja' ? '✍️ 文化感想を書く'
            : '✍️ Write your thoughts';
          const bonus = lang === 'zh' ? '每篇 300 字  +$1000'
            : lang === 'ko' ? '300자마다  +$1000'
            : lang === 'ja' ? '毎篇 300 字  +$1000'
            : 'each 300+ chars  +$1000';
          return (
            <button
              className="mdm-write-culture with-bonus"
              type="button"
              onClick={() => {
                window.dispatchEvent(new CustomEvent('cony-write-culture', {
                  detail: { topic: { title: showTitle, credit: showCredit, type: m.type, year: m.year } },
                }));
                onClose?.();
              }}
            >
              <span className="mwc-label">{label}</span>
              <span className="mwc-bonus">{bonus}</span>
            </button>
          );
        })()}

        <div className="mdm-actions">
          <button className="btn ghost" type="button" onClick={onClose}>{T('btn.close') || 'Close'}</button>
          <button className="btn primary" type="button" onClick={openExternal}>
            {wiki?.source ? (T('mdm.wikiOpen') || 'Open Wikipedia ↗') : (T('mdm.searchExt') || 'Search ↗')}
          </button>
        </div>
      </div>
    </div>
  );
}

/* ===== Culture diary modal · "write 300+ chars, earn $1000 once" ===== */
function CultureDiaryModal({ topic, cityName, countryId, priorClaim, capped, claimCount, claimMax, onSubmit, onClose }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const MIN_CHARS = 300;
  // Re-localize the city display at render time. If upstream passes the
  // Chinese name (older cached app.jsx) but the UI is in another language,
  // we still find the right city object and show the right field.
  const cityDisplay = (() => {
    if (!cityName) return '';
    const found = (window.CITIES || []).find(c => c.name === cityName || c.name_zh === cityName);
    if (!found) return cityName;
    return lang === 'zh' ? (found.name_zh || found.name) : (found.name || found.name_zh);
  })();
  const countryLabel = countryId && window.countryLabel ? window.countryLabel(countryId) : (countryId || '');
  // Reuse the parent's matcher logic — local copy so this component stays standalone.
  const matchesText = (body, needle) => {
    if (!body || !needle) return false;
    const b = body.toLowerCase();
    const n = String(needle).toLowerCase().trim();
    if (!n) return false;
    if (b.includes(n)) return true;
    const parts = n.split(/[\s·,，。、！？!?\.\-—:：]+/).filter(p => p.length >= 2);
    return parts.some(p => b.includes(p));
  };
  // Same quality heuristic as app.jsx · returns { ok, reason }.
  const entryQuality = (raw) => {
    if (!raw) return { ok: true, reason: null };  // empty body — checked elsewhere
    const chars = [...raw].filter(c => !/\s/.test(c));
    if (chars.length < 50) return { ok: true };
    const lower = chars.map(c => c.toLowerCase ? c.toLowerCase() : c);
    const unique = new Set(lower);
    if (unique.size / chars.length < 0.12 || unique.size < 20) return { ok: false, reason: 'low-variety' };
    let maxRun = 1, run = 1;
    for (let i = 1; i < lower.length; i++) {
      if (lower[i] === lower[i - 1]) { run++; if (run > maxRun) maxRun = run; }
      else run = 1;
    }
    if (maxRun > 10) return { ok: false, reason: 'repeat-run' };
    const ngrams = new Map();
    for (let i = 0; i <= lower.length - 4; i++) {
      const k = lower.slice(i, i + 4).join('');
      ngrams.set(k, (ngrams.get(k) || 0) + 1);
    }
    let maxN = 0;
    for (const v of ngrams.values()) { if (v > maxN) maxN = v; }
    if (maxN > 12) return { ok: false, reason: 'repeat-phrase' };
    return { ok: true };
  };

  const locLabel = [cityDisplay, countryLabel].filter(Boolean).join(' · ');
  const topicName = topic?.title || '';
  const copy = lang === 'zh' ? {
    title: '写一篇文化心得',
    sub: locLabel,
    topicTag: '关于',
    titlePh: '心得标题（可选）',
    bodyPh: `写下你对${topicName ? `「${topicName}」` : '这件作品 / 这个地方'}的感受、记忆、联想——要在正文里提到「${topicName || '对象'}」和${cityDisplay ? `「${cityDisplay}」` : '地点'}。300 字以上才领补贴。`,
    counterMin: '字  · 至少',
    counterEnough: '字  · 已达标',
    chkLead: '满足任意 3 条即可领 +$1000',
    chkChars: '300 字以上',
    chkTopic: topicName ? `提到「${topicName}」` : '提到对象名称',
    chkLoc: locLabel ? `提到「${cityDisplay || countryLabel}」` : '提到地点',
    chkPub: '设为公开',
    qLow: '⚠ 内容审核未通过：字符种类太少、重复过多 — 请写得更具体一些',
    qReasonVar: '内容多样性不足',
    qReasonRun: '出现大量重复字符',
    qReasonPhrase: '同一短语重复出现',
    pubLabel: '公开',
    pubHint: '公开后好友能在你的旅行日记里看到（领取补贴必须公开）',
    cancel: '取消',
    save: '保存日记',
    saveBonus: '保存 · 可领 +$1000',
    successBonus: '✓ 已存入日记，请领取补贴',
    successNoBonus: '✓ 已存入日记',
    statusDup: '⚠ 这件作品/景点本局已经领过补贴了，本次只保存日记。',
    statusCap: `⚠ 本局已领满 ${claimMax} 次旅行补贴，本次只保存日记。`,
    statusOK: `本局补贴：${claimCount} / ${claimMax}`,
  } : lang === 'ko' ? {
    title: '문화 감상문 쓰기',
    sub: locLabel,
    topicTag: '대상',
    titlePh: '제목 (선택)',
    bodyPh: `${topicName ? `「${topicName}」` : '이 작품/장소'}에 대한 감상… 본문에 「${topicName || '대상'}」과 ${cityDisplay ? `「${cityDisplay}」` : '장소'}을 언급해야 합니다. 300자 이상.`,
    counterMin: '자  · 최소',
    counterEnough: '자  · 충족',
    chkLead: '4가지 중 3가지만 충족하면 +$1000',
    chkChars: '300자 이상',
    chkTopic: topicName ? `「${topicName}」 언급` : '대상 이름 언급',
    chkLoc: locLabel ? `「${cityDisplay || countryLabel}」 언급` : '장소 언급',
    chkPub: '공개로 설정',
    qLow: '⚠ 내용 심사 미통과: 문자 다양성 부족 또는 반복 과다 — 더 구체적으로 작성해 주세요',
    qReasonVar: '내용 다양성 부족',
    qReasonRun: '같은 글자가 너무 많이 연속',
    qReasonPhrase: '같은 문구 반복',
    pubLabel: '공개',
    pubHint: '공개하면 친구가 일기에서 볼 수 있어요 (보조금은 공개 필수)',
    cancel: '취소',
    save: '일기 저장',
    saveBonus: '저장 · $1000 받기',
    successBonus: '✓ 저장됨. 보조금을 받으세요',
    successNoBonus: '✓ 저장됨',
    statusDup: '⚠ 이 작품/명소에 대한 보조금은 이번 게임에서 이미 받았어요.',
    statusCap: `⚠ 이번 게임 보조금 ${claimMax}회 한도 도달.`,
    statusOK: `이번 게임 보조금: ${claimCount} / ${claimMax}`,
  } : {
    title: 'Write a culture entry',
    sub: locLabel,
    topicTag: 'About',
    titlePh: 'Title (optional)',
    bodyPh: `Your thoughts on ${topicName ? `"${topicName}"` : 'this work / place'} — must mention "${topicName || 'the topic'}" and ${cityDisplay ? `"${cityDisplay}"` : 'the location'}. 300+ chars to earn the stipend.`,
    counterMin: 'chars · min',
    counterEnough: 'chars · ok',
    chkLead: 'Any 3 of 4 → +$1000',
    chkChars: '300+ chars',
    chkTopic: topicName ? `mentions "${topicName}"` : 'mentions the topic',
    chkLoc: locLabel ? `mentions "${cityDisplay || countryLabel}"` : 'mentions the place',
    chkPub: 'set Public',
    qLow: '⚠ Quality check failed — too repetitive or too few unique chars. Try writing more specifically.',
    qReasonVar: 'low character variety',
    qReasonRun: 'long runs of identical chars',
    qReasonPhrase: 'phrase repeats too often',
    pubLabel: 'Public',
    pubHint: 'Public entries appear in your friends\' feed (required for stipend)',
    cancel: 'Cancel',
    save: 'Save entry',
    saveBonus: 'Save · claim $1000',
    successBonus: '✓ Saved. Claim your stipend.',
    successNoBonus: '✓ Saved.',
    statusDup: '⚠ Already claimed a stipend for this topic this run — entry will save without bonus.',
    statusCap: `⚠ Stipend cap (${claimMax}/run) reached — entry will save without bonus.`,
    statusOK: `Stipend this run: ${claimCount} / ${claimMax}`,
  };

  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');
  const [visibility, setVisibility] = useState('private');
  const bodyRef = useRef(null);

  // Quick-insert chips: tapping one drops the text into the body at the cursor
  // (or appends if no selection). Lets the player insert names they can't type
  // because of language/diacritic issues like "REYKJAVÍK" / "Hallgrímskirkja".
  const insertIntoBody = (text) => {
    if (!text) return;
    const el = bodyRef.current;
    if (el && typeof el.selectionStart === 'number') {
      const start = el.selectionStart;
      const end = el.selectionEnd;
      const before = body.slice(0, start);
      const after = body.slice(end);
      const sep = before && !/\s$/.test(before) ? ' ' : '';
      const next = before + sep + text + (after.startsWith(' ') ? '' : ' ') + after;
      setBody(next);
      // Restore caret after the inserted token
      setTimeout(() => {
        const pos = (before + sep + text + ' ').length;
        try { el.focus(); el.setSelectionRange(pos, pos); } catch (_) {}
      }, 0);
    } else {
      setBody(b => (b ? b + (b.endsWith(' ') ? '' : ' ') : '') + text + ' ');
    }
  };
  // Build the chip list — deduped, only names that actually exist.
  const insertChips = (() => {
    const set = new Set();
    const push = (s) => { if (s && !set.has(s)) set.add(s); };
    if (topic?.title) push(topic.title);
    if (topic?.credit) push(topic.credit);
    push(cityDisplay);
    push(foundCity?.name);
    push(foundCity?.name_zh);
    push(countryLabel);
    return Array.from(set).slice(0, 5);
  })();
  const [saving, setSaving] = useState(false);
  const [done, setDone] = useState({ closed: false, paid: false });
  const charCount = [...body].length;
  const enough = charCount >= MIN_CHARS;
  const topicMentioned = matchesText(body, topic?.title) || matchesText(body, topic?.credit);
  // Try ALL known names of the city + country — body may be written in any language.
  const foundCity = (window.CITIES || []).find(c => c.name === cityName || c.name_zh === cityName);
  const locCandidates = [cityName, foundCity?.name, foundCity?.name_zh, countryLabel].filter(Boolean);
  const locMentioned = locCandidates.some(n => matchesText(body, n))
                       || (countryId && body.toUpperCase().includes(countryId));
  const isPublic = visibility === 'public';
  // Rule: any 3 of 4 user-controlled checks pass + content quality OK → eligible.
  const passCount = (enough?1:0) + (topicMentioned?1:0) + (locMentioned?1:0) + (isPublic?1:0);
  const qualityResult = enough ? entryQuality(body) : { ok: true };
  const bonusBlocked = priorClaim || capped;
  const bonusEligible = passCount >= 3 && qualityResult.ok && !bonusBlocked;

  const submit = () => {
    if (!enough || saving) return;
    setSaving(true);
    const topicTitle = topic?.title || '';
    const defaultTitle = topicTitle ? `${copy.topicTag}「${topicTitle}」` : copy.title;
    const entry = {
      id: `d-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`,
      title: title.trim() || defaultTitle,
      mood: '✍️',
      body: body.trim(),
      cityName: cityName || '',
      countryId: countryId || '',
      visibility,
      ts: Date.now(),
      topic: topic ? { ...topic } : null,
    };
    const result = onSubmit?.(entry);
    setDone({ closed: true, paid: !!result?.paid });
    setTimeout(() => onClose?.(), 1200);
  };

  return (
    <div className="modal-overlay culture-diary-overlay" onClick={onClose}>
      <div className="modal culture-diary-modal" onClick={e => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose} aria-label="close">×</button>
        <div className="cd-head">
          <div className="cd-title">{copy.title}</div>
          {copy.sub && <div className="cd-sub">📍 {copy.sub}</div>}
        </div>
        {topic && (() => {
          // Localize the type pill — BOOK / FILM / SONG / FOOD have i18n keys,
          // LANDMARK doesn't, so fall back to a per-language hardcoded word.
          const fallbackType = lang === 'zh' ? '景点'
            : lang === 'ko' ? '명소'
            : lang === 'ja' ? '名所'
            : 'Landmark';
          const typed = topic.type
            ? (T(`mtype.${topic.type}`) !== `mtype.${topic.type}`
                ? T(`mtype.${topic.type}`)
                : (topic.type === 'LANDMARK' ? fallbackType : topic.type))
            : '';
          return (
            <div className="cd-topic">
              <div className={`cd-topic-type ${topic.type || ''}`}>{typed}</div>
              <div className="cd-topic-body">
                <div className="cd-topic-title">{topic.title}</div>
                {topic.credit && <div className="cd-topic-credit">{topic.credit}{topic.year ? ` · ${topic.year}` : ''}</div>}
              </div>
            </div>
          );
        })()}

        <input
          type="text"
          className="cd-title-input"
          placeholder={copy.titlePh}
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          maxLength={120}
        />

        <textarea
          className="cd-body"
          placeholder={copy.bodyPh}
          value={body}
          onChange={(e) => setBody(e.target.value)}
          rows={9}
          autoFocus
          ref={bodyRef}
        />

        {/* Quick-insert chips for required tokens — tap to drop into body. */}
        {insertChips.length > 0 && (
          <div className="cd-chips">
            <span className="cd-chips-lbl">
              {lang === 'zh' ? '点击插入：' : lang === 'ko' ? '탭하여 삽입:' : lang === 'ja' ? 'タップで挿入:' : 'Tap to insert:'}
            </span>
            {insertChips.map(c => (
              <button
                key={c}
                type="button"
                className="cd-chip"
                onClick={() => insertIntoBody(c)}
              >+ {c}</button>
            ))}
          </div>
        )}

        <div className={`cd-counter ${enough ? 'ok' : ''}`}>
          <span className="cd-counter-n">{charCount}</span>
          <span className="cd-counter-sep">/</span>
          <span className="cd-counter-min">{MIN_CHARS}</span>
          <span className="cd-counter-lbl">{enough ? copy.counterEnough : copy.counterMin} {!enough && `${MIN_CHARS}`}</span>
        </div>

        {/* Stipend checklist — four lights, any 3 green to qualify. */}
        <div className="cd-checks-lead">{copy.chkLead}  ·  {passCount}/4</div>
        <ul className="cd-checks">
          <li className={`cd-check ${enough ? 'ok' : ''}`}>
            <span className="cd-mark">{enough ? '✓' : '○'}</span>
            <span>{copy.chkChars}</span>
          </li>
          <li className={`cd-check ${topicMentioned ? 'ok' : ''}`}>
            <span className="cd-mark">{topicMentioned ? '✓' : '○'}</span>
            <span>{copy.chkTopic}</span>
          </li>
          <li className={`cd-check ${locMentioned ? 'ok' : ''}`}>
            <span className="cd-mark">{locMentioned ? '✓' : '○'}</span>
            <span>{copy.chkLoc}</span>
          </li>
          <li className={`cd-check ${isPublic ? 'ok' : ''}`}>
            <span className="cd-mark">{isPublic ? '✓' : '○'}</span>
            <span>{copy.chkPub}</span>
          </li>
        </ul>

        {/* Stipend run-state — dup / cap warnings dominate when present,
            otherwise show the counter so the player sees their progress. */}
        <div className={`cd-status ${priorClaim || capped ? 'warn' : ''}`}>
          {capped ? copy.statusCap : priorClaim ? copy.statusDup : copy.statusOK}
        </div>

        {/* Quality audit — only shown when the body is long enough to be checked
            AND has failed the heuristic. Doesn't block save, just the bonus. */}
        {enough && !qualityResult.ok && (
          <div className="cd-quality-warn">
            {copy.qLow}
            <span className="cd-quality-reason">
              {qualityResult.reason === 'low-variety' ? copy.qReasonVar
                : qualityResult.reason === 'repeat-run' ? copy.qReasonRun
                : qualityResult.reason === 'repeat-phrase' ? copy.qReasonPhrase
                : ''}
            </span>
          </div>
        )}

        <label className="cd-pub">
          <input
            type="checkbox"
            checked={visibility === 'public'}
            onChange={(e) => setVisibility(e.target.checked ? 'public' : 'private')}
          />
          <span className="cd-pub-lbl">{copy.pubLabel}</span>
          <span className="cd-pub-hint">{copy.pubHint}</span>
        </label>

        {done.closed ? (
          <div className={`cd-success ${done.paid ? 'paid' : 'noop'}`}>
            {done.paid ? copy.successBonus : copy.successNoBonus}
          </div>
        ) : (
          <div className="cd-actions">
            <button className="btn ghost" type="button" onClick={onClose} disabled={saving}>{copy.cancel}</button>
            <button
              className={`btn primary cd-save ${bonusEligible ? 'with-bonus' : ''}`}
              type="button"
              onClick={submit}
              disabled={!enough || saving}
            >
              {bonusEligible ? copy.saveBonus : copy.save}
            </button>
          </div>
        )}
      </div>
    </div>
  );
}

/* song preview: prefers explicit m.youtube (video id or url) for in-page embed;
   otherwise opens YouTube search in a new tab (search-list embed is blocked by YT). */
function ytIdFromUrl(s) {
  if (!s) return '';
  if (!/[\/=]/.test(s)) return s; // already a bare id
  const m = s.match(/(?:v=|youtu\.be\/|\/embed\/|\/shorts\/)([\w-]{11})/);
  return m ? m[1] : '';
}
function SongPreview({ seed, title, credit, youtube }) {
  const [loaded, setLoaded] = useState(false);
  const query = encodeURIComponent(`${title} ${credit}`);
  const ytId = ytIdFromUrl(youtube);
  const ytSearchUrl = `https://www.youtube.com/results?search_query=${query}`;
  const embedSrc = ytId
    ? `https://www.youtube-nocookie.com/embed/${ytId}?autoplay=1&modestbranding=1&rel=0`
    : '';
  const spQuery = encodeURIComponent(`${title} ${credit}`);

  const onCoverClick = (e) => {
    e.stopPropagation();
    if (ytId) { setLoaded(true); return; }
    window.open(ytSearchUrl, '_blank', 'noopener');
  };

  return (
    <div className="song-preview" onClick={(e)=>e.stopPropagation()}>
      {!loaded && (
        <button className="yt-cover" onClick={onCoverClick} aria-label="play">
          <span className="yt-cover-disc"></span>
          <span className="yt-cover-play">
            <svg viewBox="0 0 24 24" width="20" height="20"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
          </span>
          <span className="yt-cover-meta">
            <span className="yt-cover-title">{title}</span>
            <span className="yt-cover-credit">{credit} · {ytId ? 'Play on YouTube' : 'Search YouTube'}</span>
          </span>
        </button>
      )}
      {loaded && ytId && (
        <div className="yt-frame-wrap">
          <iframe
            className="yt-frame"
            src={embedSrc}
            title={`${title} — ${credit}`}
            frameBorder="0"
            allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
            allowFullScreen
          />
        </div>
      )}
      <div className="song-note">
        <a href={ytSearchUrl} target="_blank" rel="noopener"
           onClick={(e)=>e.stopPropagation()}>YouTube</a>
        {' · '}
        <a href={`https://open.spotify.com/search/${spQuery}`} target="_blank" rel="noopener"
           onClick={(e)=>e.stopPropagation()}>Spotify</a>
      </div>
    </div>
  );
}
/* ================= city panel ================= */
function CityPanel({ city, onClose, balance, onEarn, onPayslip, pet }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const [openId, setOpenId] = useState(city.landmarks?.[0]?.id);
  const [landmarkDetail, setLandmarkDetail] = useState(null);
  useEffect(() => { setOpenId(city.landmarks?.[0]?.id); }, [city.id]);
  if (!city) return null;
  const title = lang === 'zh' ? (city.name_zh || city.name) : (city.name || city.name_zh);
  // sub-title in non-zh mode: don't show the Chinese name; just show country code (already after the sep)
  const sub = lang === 'zh' ? city.name : '';
  return (
    <div className="city-panel">
      <div className="panel-header">
        <div className="panel-tag">{T('section.archive')}</div>
        <div className="panel-title">{title}</div>
        <div className="panel-subtitle">
          {sub && <>{sub}<span className="sep"></span></>}
          {city.country}
          <span className="sep"></span>
          {T('panel.population')} {city.pop}
        </div>
        <div className="panel-coords">
          <span><span className="l">{T('panel.lat')}</span><span className="v">{city.lat.toFixed(2)}°</span></span>
          <span><span className="l">{T('panel.lon')}</span><span className="v">{city.lon.toFixed(2)}°</span></span>
          <span><span className="l">{T('panel.tz')}</span><span className="v">{city.tz}</span></span>
        </div>
        <button className="panel-close" onClick={onClose}>×</button>
      </div>
      {window.COUNTRIES && window.COUNTRIES[city.country] && (
        <button className="deep-dive-btn" onClick={(e) => {
          const rect = e.currentTarget.closest('.city-panel')?.getBoundingClientRect();
          // try to find the city node on the map for accurate origin
          const node = document.querySelector('.city.current');
          let ox = 50, oy = 50;
          if (node) {
            const r = node.getBoundingClientRect();
            ox = (r.left + r.width / 2) / window.innerWidth * 100;
            oy = (r.top + r.height / 2) / window.innerHeight * 100;
          }
          window.dispatchEvent(new CustomEvent('open-country', {
            detail: { id: city.country, originX: ox, originY: oy }
          }));
        }}>
          <span className="dd-icon" aria-hidden="true">
            <svg viewBox="0 0 24 24" width="20" height="20">
              <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.6"/>
              <path d="M12 3v18 M3 12h18" stroke="currentColor" strokeWidth="1.2" opacity="0.5"/>
              <path d="M14.5 9.5l-1.6 5.4-5.4 1.6 1.6-5.4z" fill="currentColor" opacity="0.85"/>
            </svg>
          </span>
          <span className="dd-label">
            <span className="dd-overline">{T('section.deepmap')}</span>
            <span className="dd-title">{lang === 'zh' ? `深入探索 ${window.COUNTRIES[city.country].name_zh}` : (window.COUNTRIES[city.country].name || window.COUNTRIES[city.country].name_zh)}</span>
          </span>
          <span className="dd-arrow">→</span>
        </button>
      )}
      {city.workHoliday && city.jobs && city.jobs.length > 0 && onEarn && (
        <JobBoard city={city} balance={balance} onEarn={onEarn} onPayslip={onPayslip} pet={pet}/>
      )}
      {(city.landmarks || []).length > 0 && (
        <>
          <div className="panel-section-title">
            <span>{T('section.landmarks')}</span>
            <span className="count">{(city.landmarks || []).length}</span>
          </div>
          <div className="media-grid country-media landmark-grid">
            {(city.landmarks || []).map(lm => {
              const lmName = window.localizeLandmarkName
                ? window.localizeLandmarkName(lm.name, lm.name_zh)
                : (lang === 'zh' ? (lm.name_zh || lm.name) : (lm.name || lm.name_zh));
              return (
                <div
                  key={lm.id}
                  className="media-card tap landmark-card"
                  onClick={() => setLandmarkDetail({
                    type: 'LANDMARK',
                    title: lm.name || lm.name_zh,    // English (or zh fallback) for Wikipedia lookup
                    name_zh: lm.name_zh,
                    credit: lm.meta,
                    landmarkMedia: lm.media || [],   // nested books/films for some landmarks
                  })}
                >
                  <div className="media-row">
                    <div className="media-type-tag LANDMARK">{T('section.landmarks') || 'LANDMARK'}</div>
                    <div className="media-body">
                      <div className="media-title">{lmName}</div>
                      <div className="media-credit">{lm.meta}</div>
                    </div>
                    <span className="media-detail-cue">›</span>
                  </div>
                </div>
              );
            })}
          </div>
          {landmarkDetail && (
            <MediaDetailModal m={landmarkDetail} onClose={() => setLandmarkDetail(null)} />
          )}
        </>
      )}
      {/* Show country culture + cuisine ALWAYS when landmarks lack their own media,
          OR when there are no landmarks at all. The landmark itself is just an
          attraction name; cultural works come from COUNTRY_MEDIA. */}
      {(() => {
        const hasLandmarks = city.landmarks && city.landmarks.length > 0;
        const hasLandmarkMedia = hasLandmarks && city.landmarks.some(lm => lm.media && lm.media.length > 0);
        if (hasLandmarks && hasLandmarkMedia) return null;
        const fallback = (window.COUNTRY_MEDIA && window.COUNTRY_MEDIA[city.country]) || [];
        if (fallback.length === 0) return null;
        const cultural = fallback.filter(m => m.type !== 'FOOD');
        const cuisine  = fallback.filter(m => m.type === 'FOOD');
        return (
          <>
            {cultural.length > 0 && (
              <>
                <div className="panel-section-title">
                  <span>{T('section.media') || 'Culture'}</span>
                  <span className="count">{cultural.length}</span>
                </div>
                <div className="media-grid country-media cultural-media">
                  {cultural.map((m, i) => <MediaCard key={i} m={m}/>)}
                </div>
              </>
            )}
            {cuisine.length > 0 && (
              <>
                <div className="panel-section-title cuisine-title">
                  <span>{T('mtype.FOOD') || 'Cuisine'}</span>
                  <span className="count">{cuisine.length}</span>
                </div>
                <div className="media-grid country-media cuisine-media">
                  {cuisine.map((m, i) => <MediaCard key={`f${i}`} m={m}/>)}
                </div>
              </>
            )}
          </>
        );
      })()}
    </div>
  );
}

/* ================= intro tutorial ================= */
function Intro({ onStart, savedStartId }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const initial = (window.INITIAL_BALANCE || 20000);

  // True random spawn — Intro only renders when game hasn't started.
  // Free-tier players spawn within the unlocked-cities pool so they aren't
  // immediately stranded somewhere they can't return to. Paid players can
  // wake up anywhere on the map.
  const [spawn] = useState(() => {
    const all = (window.CITIES || []);
    const pool = (window.isPaidUser && window.isPaidUser())
      ? all
      : all.filter(c => !window.isCityUnlocked || window.isCityUnlocked(c.id));
    const src = pool.length ? pool : all;
    return src[Math.floor(Math.random() * src.length)];
  });

  if (!spawn) return null;

  const cityName = lang === 'zh' ? (spawn.name_zh || spawn.name) : (spawn.name || spawn.name_zh);
  const countryName = (window.countryLabel ? window.countryLabel(spawn.country) : spawn.country);
  const flag = (window.flagFor ? window.flagFor(spawn.country) : '🌐');
  const tier = window.getCountryTier ? window.getCountryTier(spawn.country) : 3;
  const tierColors = { 1:'#7ec78a', 2:'#b8924c', 3:'#d4a574', 4:'#c97b6a', 5:'#8a5444' };

  return (
    <div className="intro">
      <div className="intro-inner intro-spawn">
        <div className="intro-mark">Pathfinder<span className="dot"></span></div>
        <div className="intro-kr">{lang === 'zh' ? '文化旅行地图' : 'Cultural Transit Atlas'}</div>
        <div className="intro-tag">{T('intro.tagline')}</div>

        <div className="spawn-card">
          <div className="spawn-label">{T('intro.spawnedIn') || (lang === 'zh' ? '你降落在' : 'You woke up in')}</div>
          <div className="spawn-flag">{flag}</div>
          <div className="spawn-city">{cityName}</div>
          <div className="spawn-country">{countryName}</div>
          <div className="spawn-tier-row">
            <span className="spawn-tier-label">{T('visa.passport') || (lang === 'zh' ? '护照等级' : 'Passport')}</span>
            <span className="spawn-tier-pill" style={{background: tierColors[tier], color:'white'}}>T{tier}</span>
          </div>
          <div className="spawn-hint">{T('intro.spawnHint') ||
            (lang === 'zh'
              ? '你的国籍由降落地点决定。前往非免签国家需要申请签证。'
              : 'Your nationality is the city you wake up in. Visiting countries without visa-free access requires a visa.')}</div>
        </div>

        <button className="intro-cta" onClick={() => onStart(spawn.id)}>
          {T('intro.beginJourney') || (lang === 'zh' ? '开始旅程' : 'Begin journey')}
          <span className="arrow">→</span>
        </button>
        <div className="spawn-locked">
          {T('intro.locked') ||
            (lang === 'zh' ? '本局国籍固定，无法重选' : 'Nationality is locked for this run')}
        </div>
        <div className="intro-skip">
          ${initial.toLocaleString()} {T('intro.startingFunds') || (lang === 'zh' ? '启动资金' : 'starting funds')}
        </div>
      </div>
    </div>
  );
}

/* ================= hint banner ================= */
function HintBanner({ step, dest }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  let n, text;
  if (lang === 'zh') {
    if (step === 'pick')    { n = 1; text = <>点击地图上的城市节点，设为<strong>目的地</strong>。</>; }
    else if (step === 'transit') { n = 2; text = <>在下方选择交通工具，然后点<strong>出发</strong>或按<strong>空格</strong>。</>; }
    else if (step === 'arrive')  { n = 3; text = <>欢迎来到 <strong>{dest}</strong>。点击地标查看歌曲、电影与书籍。</>; }
    else return null;
  } else {
    if (step === 'pick')    { n = 1; text = <>Tap a city on the map to set it as your <strong>destination</strong>.</>; }
    else if (step === 'transit') { n = 2; text = <>Pick a transport below, then hit <strong>{T('btn.depart')}</strong> or press <strong>Space</strong>.</>; }
    else if (step === 'arrive')  { n = 3; text = <>{T('common.welcome')}, <strong>{dest}</strong>. Tap a landmark for songs, films and books.</>; }
    else return null;
  }
  return (
    <div className="hint">
      <div className="num">{n}</div>
      <div>{text}</div>
    </div>
  );
}

/* ================= country view (zoom-in) ================= */
function CountryView({ countryId, onClose, originX = 50, originY = 50, entryHint, pet, balance = 0, onSpend, onEarn, onTicket, onPayslip, visitedSubIds }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const cityLabel = (c) => lang === 'zh' ? (c.name_zh || c.name) : (c.name || c.name_zh);
  // dual-line label: localized name + English (only when there are differing names)
  const cityLabelDual = (c) => {
    const primary = lang === 'zh' ? (c.name_zh || c.name) : (c.name || c.name_zh);
    const secondary = lang === 'zh' ? (c.name && c.name !== c.name_zh ? c.name : '') : '';
    return { primary, secondary };
  };
  const country = window.COUNTRIES[countryId];
  const cities = country?.subCities || [];
  const primaryId = cities.find(s => s.primary)?.id || cities[0]?.id;
  // if a hint name is provided, prefer matching sub-city as the player's current location
  const matchByHint = entryHint
    ? cities.find(s => (s.name_zh === entryHint) || (s.name === entryHint))
    : null;
  const startId = matchByHint ? matchByHint.id : primaryId;
  const [currentSubId, setCurrentSubId] = useState(startId);
  const [openId, setOpenId] = useState(startId);
  const [enter, setEnter] = useState(false);
  const [exiting, setExiting] = useState(false);
  const [transit, setTransit] = useState(null);
  const [encounter, setEncounter] = useState(null);
  const [pendingDomestic, setPendingDomestic] = useState(null);
  useEffect(() => {
    requestAnimationFrame(() => requestAnimationFrame(() => setEnter(true)));
  }, []);
  // trigger a street encounter the first time the player lands in this sub-city this session
  useEffect(() => {
    if (!currentSubId) return;
    const seen = window.__conyEncountered || (window.__conyEncountered = new Set());
    if (seen.has(currentSubId)) return;
    seen.add(currentSubId);
    const city = cities.find(c => c.id === currentSubId);
    if (!city) return;
    // small delay so the deep-dive animation finishes first
    const t = setTimeout(() => setEncounter(city), 1500);
    return () => clearTimeout(t);
  }, [currentSubId]); // eslint-disable-line
  // (safety briefing removed per simplification — was off-theme and interrupted travel flow)
  if (!country) return null;

  const handleClose = () => {
    setExiting(true);
    setEnter(false);
    setTimeout(onClose, 520);
  };

  const handleDomesticTravel = (toId, transportId, cabinClassId) => {
    if (transit) return;
    const fromCity = cities.find(c => c.id === currentSubId);
    const toCity = cities.find(c => c.id === toId);
    const transport = window.TRANSPORTS.find(t => t.id === transportId);
    if (!fromCity || !toCity || !transport) return;
    const cost = window.calcDomesticCost
      ? window.calcDomesticCost(fromCity, toCity, transport, cabinClassId, countryId)
      : 0;
    const petFee = (pet && window.calcPetFee) ? window.calcPetFee(transportId, cost) : 0;
    if (cost + petFee > balance && balance < 1000000) return;
    setPendingDomestic({ fromCity, toCity, toId, transport, cabinClassId, cost, petFee });
  };
  const confirmDomestic = () => {
    if (!pendingDomestic) return;
    const { fromCity, toCity, toId, transport, cabinClassId, cost, petFee } = pendingDomestic;
    setPendingDomestic(null);
    onSpend?.(cost + petFee);
    setTransit({ from: fromCity, to: toCity, transport, cost, petFee });
    setTimeout(() => {
      const ticket = makeTicket({
        from: fromCity, to: toCity, transport,
        cabinClassId, scope: 'domestic', countryId, pet,
        costUSD: cost, petFeeUSD: petFee,
      });
      onTicket?.(ticket);
      setCurrentSubId(toId);
      setOpenId(toId);
      setTransit(null);
    }, 1500);
  };

  const fromCity = cities.find(c => c.id === currentSubId);
  const toCityForRoute = openId !== currentSubId ? cities.find(c => c.id === openId) : null;
  const originStyle = { '--origin-x': `${originX}%`, '--origin-y': `${originY}%` };

  return (
    <div className={`country-overlay ${enter ? 'enter' : ''} ${exiting ? 'exit' : ''}`} style={originStyle}>
      <div className="country-zoom">
      <div className="country-stage">
        <div className="country-header">
          <div className="ch-tag">{T('section.deepmap')}</div>
          <div className="ch-title">{lang === 'zh' ? country.name_zh : country.name}</div>
          <button className="ch-close" onClick={handleClose}>← {T('btn.back')}</button>
        </div>

        <div className="country-body">
          <div className="country-map-wrap">
            <svg viewBox="0 0 100 100" className="country-svg" preserveAspectRatio="xMidYMid meet">
              <defs>
                <radialGradient id="cmap-glow" cx="50%" cy="50%" r="60%">
                  <stop offset="0%" stopColor="rgba(212, 119, 92, 0.08)"/>
                  <stop offset="100%" stopColor="rgba(212, 119, 92, 0)"/>
                </radialGradient>
                <clipPath id={`country-clip-${country.id}`}>
                  {country.shapes.map((d, i) => <path key={i} d={d}/>)}
                </clipPath>
              </defs>
              <rect x="0" y="0" width="100" height="100" fill="url(#cmap-glow)"/>
              {country.shapes.map((d, i) => (
                <path key={i} d={d} className="country-land" />
              ))}
              {country.provinces && (
                <g clipPath={`url(#country-clip-${country.id})`}>
                  {country.provinces.map(p => (
                    <path key={p.id} d={p.d} className="country-province" />
                  ))}
                </g>
              )}
              {country.provinces && country.provinces.map(p => (
                <text key={`${p.id}-label`} x={p.lx} y={p.ly}
                      className="province-label" textAnchor="middle">{lang === 'zh' ? p.name_zh : (p.name || p.name_zh)}</text>
              ))}
              {fromCity && toCityForRoute && (
                <path className="sub-route"
                      d={`M ${fromCity.x} ${fromCity.y} L ${toCityForRoute.x} ${toCityForRoute.y}`}/>
              )}
              {cities.map(sc => {
                const isVisited = visitedSubIds && visitedSubIds.has(sc.id);
                return (
                  <g key={sc.id}
                     className={`sub-city ${sc.id === openId ? 'open' : ''} ${sc.id === currentSubId ? 'here' : ''} ${sc.primary ? 'primary' : ''} ${isVisited ? 'visited' : ''}`}
                     transform={`translate(${sc.x} ${sc.y})`}
                     onClick={() => setOpenId(sc.id)}>
                    <circle r="3.6" className="sub-halo"/>
                    <circle r="1.6" className="sub-dot"/>
                    {isVisited && (
                      <text y="-3.2" textAnchor="middle" className="sub-flag">{flagOf(countryId)}</text>
                    )}
                    <text y={isVisited ? "-5" : "-3"} textAnchor="middle" className="sub-label">{cityLabel(sc)}</text>
                  </g>
                );
              })}
            </svg>
          </div>

          <CityDeck
            cities={cities}
            openId={openId}
            currentId={currentSubId}
            onChange={setOpenId}
            onTravel={handleDomesticTravel}
            balance={balance}
            countryId={countryId}
            onEarn={onEarn}
            onPayslip={onPayslip}
            pet={pet}
          />
        </div>
        {transit && (
          <div className="transit-veil">
            <div className="transit-card">
              <div className="tv-tag">{T(`trans.${transit.transport.id}`)} · {T('app.duration')}</div>
              <div className="tv-route">
                <span>{cityLabel(transit.from)}</span>
                <span className="tv-arrow">———▸</span>
                <span>{cityLabel(transit.to)}</span>
              </div>
              <div className="tv-dots"><span/><span/><span/></div>
            </div>
          </div>
        )}
        {encounter && (
          <EncounterModal
            city={encounter}
            onClose={() => setEncounter(null)}
            onReward={(usd) => { if (usd > 0) onEarn?.(usd); else if (usd < 0) onSpend?.(Math.abs(usd)); }}
          />
        )}
        {/* SafetyScenario removed — was off-theme / interrupted travel flow */}
        {pendingDomestic && (
          <DepartConfirm
            from={pendingDomestic.fromCity}
            to={pendingDomestic.toCity}
            transport={pendingDomestic.transport}
            cabinClass={pendingDomestic.cabinClassId}
            pet={pet}
            scope="domestic"
            countryId={countryId}
            balance={balance}
            onCancel={() => setPendingDomestic(null)}
            onConfirm={confirmDomestic}
          />
        )}
      </div>
      </div>
    </div>
  );
}

/* ===== seasonal overlay · 真实日期触发城市限定主题 ===== */
function currentMonth() { return new Date().getMonth() + 1; } // 1-12
const SEASONAL_OVERLAYS = {
  // spring 3-4 月
  '东京':   { months:[3,4],     emoji:'🌸', label:'樱花季',   tone:'sakura', tip:'樱花花期，目黑川夜灯刚刚结束。' },
  '京都':   { months:[3,4],     emoji:'🌸', label:'樱花季',   tone:'sakura', tip:'哲学之道两侧樱树齐放。' },
  '华盛顿': { months:[3,4],     emoji:'🌸', label:'樱花季',   tone:'sakura', tip:'潮汐湖畔国家樱花节。' },
  // summer 6-8 月
  '冲绳':   { months:[6,7,8],   emoji:'🏝️', label:'夏季限定', tone:'summer', tip:'珊瑚海夏潜旺季。' },
  '札幌':   { months:[7,8],     emoji:'🍻', label:'啤酒季',   tone:'summer', tip:'大通公园啤酒花园开放。' },
  '普罗旺斯':{ months:[6,7],    emoji:'💜', label:'薰衣草季', tone:'lavender', tip:'瓦朗索勒高原全紫。' },
  '圣托里尼':{ months:[6,7,8],  emoji:'🌅', label:'夏季限定', tone:'summer', tip:'伊亚日落最佳时节。' },
  // autumn 9-11 月
  '京都·秋':{ months:[11],      emoji:'🍁', label:'红叶季',   tone:'autumn', tip:'东福寺通天桥层林尽染。' }, // alt key handled by city id
  '波尔多': { months:[9,10],    emoji:'🍇', label:'葡萄收获季',tone:'harvest',tip:'梅多克酒庄全员上阵。' },
  '旧金山': { months:[9,10],    emoji:'🍇', label:'酒庄收获季',tone:'harvest',tip:'纳帕谷压榨开始。' },
  // winter 11-2 月
  '雷克雅未克':{ months:[11,12,1,2], emoji:'🌌', label:'极光季', tone:'aurora',  tip:'凯夫拉维克郊区可观北极光。' },
  '哈尔滨': { months:[12,1,2],  emoji:'❄️', label:'冰雪节',   tone:'winter', tip:'冰雕大世界开放。' },
  '柏林':   { months:[12],      emoji:'🎄', label:'圣诞市集', tone:'xmas', tip:'御林广场德式市集 + 烤肠 + 热红酒。' },
  '维也纳': { months:[12],      emoji:'🎄', label:'圣诞市集', tone:'xmas', tip:'美泉宫前广场华灯。' },
  '纽约':   { months:[12],      emoji:'🎄', label:'圣诞之城', tone:'xmas', tip:'洛克菲勒中心圣诞树开放滑冰。' },
  '伦敦':   { months:[12],      emoji:'🎄', label:'圣诞之城', tone:'xmas', tip:'摄政街圣诞灯饰。' },
  '皇后镇': { months:[6,7,8],   emoji:'🎿', label:'雪季',     tone:'snow', tip:'三大雪场满雪运营（南半球冬）。' },
  '悉尼':   { months:[12,1,2],  emoji:'🏖️', label:'盛夏沙滩', tone:'summer', tip:'Bondi 海滩进入旺季。' },
  '里约热内卢':{months:[2],     emoji:'🎭', label:'狂欢节',   tone:'carnival', tip:'桑巴诺梅整周游行。' },
};
function seasonalFor(city) {
  if (!city) return null;
  const overlay = SEASONAL_OVERLAYS[city.name_zh] || SEASONAL_OVERLAYS[city.name];
  if (!overlay) return null;
  if (!overlay.months.includes(currentMonth())) return null;
  return overlay;
}

/* ===== daily sale · 每日按日期推一个折扣 ===== */
/* sub-city lat/lon lookup used by weather API */
const SUBCITY_LATLON = {
  '东京':[35.68,139.69],'京都':[35.01,135.77],'大阪':[34.69,135.50],'札幌':[43.06,141.35],'福冈':[33.59,130.40],
  '北京':[39.90,116.41],'上海':[31.23,121.47],'西安':[34.34,108.94],'成都':[30.57,104.07],'广州':[23.13,113.27],'拉萨':[29.65,91.12],
  '巴黎':[48.86,2.35],'里昂':[45.76,4.83],'马赛':[43.30,5.37],'波尔多':[44.84,-0.58],'普罗旺斯':[43.95,4.81],
  '纽约':[40.71,-74.00],'洛杉矶':[34.05,-118.24],'新奥尔良':[29.95,-90.07],'芝加哥':[41.88,-87.63],'旧金山':[37.77,-122.42],
  '伦敦':[51.51,-0.13],'爱丁堡':[55.95,-3.19],'利物浦':[53.41,-2.99],'牛津':[51.75,-1.26],'巴斯':[51.38,-2.36],
  '首尔':[37.57,126.98],'釜山':[35.18,129.08],'济州岛':[33.50,126.53],'济州':[33.50,126.53],'庆州':[35.86,129.22],'全州':[35.82,127.15],'江陵':[37.75,128.88],'平昌':[37.37,128.39],
  '中环':[22.28,114.16],'尖沙咀':[22.30,114.17],'旺角':[22.32,114.17],
  '台北':[25.03,121.57],'台南':[22.99,120.21],'高雄':[22.63,120.30],
  '曼谷':[13.76,100.50],'清迈':[18.79,98.99],'普吉':[7.88,98.39],
  '滨海湾':[1.28,103.86],'牛车水':[1.28,103.84],'圣淘沙':[1.25,103.83],
  '孟买':[19.07,72.87],'德里':[28.61,77.21],'班加罗尔':[12.97,77.59],
  '伊斯坦布尔':[41.01,28.98],'安卡拉':[39.93,32.86],'伊兹密尔':[38.43,27.14],
  '柏林':[52.52,13.40],'慕尼黑':[48.14,11.58],'汉堡':[53.55,9.99],
  '罗马':[41.90,12.50],'米兰':[45.46,9.19],'威尼斯':[45.44,12.32],'那不勒斯':[40.85,14.27],
  '马德里':[40.42,-3.70],'巴塞罗那':[41.39,2.16],'塞维利亚':[37.39,-5.99],
  '里斯本':[38.72,-9.14],'波尔图':[41.15,-8.61],'法鲁':[37.02,-7.93],
  '阿姆斯特丹':[52.37,4.90],'鹿特丹':[51.92,4.48],'乌特勒支':[52.09,5.12],
  '莫斯科':[55.75,37.61],'圣彼得堡':[59.93,30.34],'海参崴':[43.12,131.89],
  '雅典':[37.98,23.73],'塞萨洛尼基':[40.64,22.93],'圣托里尼':[36.39,25.46],
  '墨西哥城':[19.43,-99.13],'坎昆':[21.16,-86.85],'瓜达拉哈拉':[20.66,-103.34],
  '里约热内卢':[-22.91,-43.17],'圣保罗':[-23.55,-46.63],'萨尔瓦多':[-12.97,-38.50],
  '布宜诺斯艾利斯':[-34.61,-58.38],'科尔多瓦':[-31.42,-64.18],'乌斯怀亚':[-54.81,-68.31],
  '开罗':[30.04,31.24],'亚历山大':[31.20,29.92],'卢克索':[25.69,32.64],
  '开普敦':[-33.92,18.42],'约翰内斯堡':[-26.20,28.04],'德班':[-29.86,31.02],
  '悉尼':[-33.87,151.21],'墨尔本':[-37.81,144.96],'乌鲁鲁':[-25.34,131.04],'珀斯':[-31.95,115.86],
  '奥克兰':[-36.85,174.76],'惠灵顿':[-41.29,174.78],'皇后镇':[-45.03,168.66],
};
function latLonOf(city, countryId) {
  const k = city.name_zh || city.name;
  if (SUBCITY_LATLON[k]) return SUBCITY_LATLON[k];
  // fallback: use the country's primary world city
  const country = window.COUNTRIES?.[countryId];
  if (country?.capitalCityId && window.CITIES) {
    const cap = window.CITIES.find(c => c.id === country.capitalCityId);
    if (cap) return [cap.lat, cap.lon];
  }
  return null;
}

/* weather (open-meteo · 无 API key · 30 分钟缓存) */
const WEATHER_CODE = {
  0:['☀','晴朗'],1:['🌤','晴'],2:['⛅','多云'],3:['☁','阴'],
  45:['🌫','雾'],48:['🌫','雾'],
  51:['🌦','小雨'],53:['🌦','雨'],55:['🌧','雨'],
  61:['🌧','雨'],63:['🌧','雨'],65:['🌧','大雨'],
  71:['🌨','小雪'],73:['🌨','雪'],75:['❄','大雪'],
  80:['🌦','阵雨'],81:['🌧','阵雨'],82:['⛈','暴雨'],
  85:['🌨','阵雪'],86:['❄','阵雪'],
  95:['⛈','雷雨'],96:['⛈','雷雨'],99:['⛈','雷暴'],
};
function useWeather(lat, lon) {
  const [w, setW] = useState(null);
  useEffect(() => {
    if (lat == null || lon == null) return;
    const key = `cony.weather.${lat.toFixed(2)},${lon.toFixed(2)}`;
    try {
      const cached = JSON.parse(localStorage.getItem(key) || 'null');
      if (cached && Date.now() - cached.ts < 30 * 60 * 1000) { setW(cached.data); return; }
    } catch {}
    let cancelled = false;
    fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,weather_code`)
      .then(r => r.json())
      .then(j => {
        if (cancelled) return;
        const c = j.current;
        if (!c) return;
        const data = { temp: c.temperature_2m, code: c.weather_code };
        try { localStorage.setItem(key, JSON.stringify({ ts: Date.now(), data })); } catch {}
        setW(data);
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, [lat, lon]);
  return w;
}
function WeatherPill({ lat, lon }) {
  const w = useWeather(lat, lon);
  if (!w) return null;
  const [icon, label] = WEATHER_CODE[w.code] || ['☁', '—'];
  return (
    <div className="weather-pill">
      <span className="wp-icon">{icon}</span>
      <span className="wp-temp">{Math.round(w.temp)}°</span>
      <span className="wp-cond">{label}</span>
    </div>
  );
}

/* small deterministic hash used to lock LoremFlickr images per city */
function encodeHash(s) {
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
  return Math.abs(h) % 99999 + 1;
}

/* Wikipedia thumbnail loader · 真实城市图片 + localStorage 缓存 */
const WIKI_CACHE = (() => {
  if (typeof window !== 'undefined' && window.__conyWikiCache) return window.__conyWikiCache;
  let c = {};
  try { c = JSON.parse(localStorage.getItem('cony.wikiCache') || '{}'); } catch {}
  if (typeof window !== 'undefined') window.__conyWikiCache = c;
  return c;
})();
// some city names need explicit Wikipedia title overrides
const WIKI_TITLE_OVERRIDES = {
  'New York': 'New York City',
  'Cape Town': 'Cape Town',
  'St Petersburg': 'Saint Petersburg',
  'Mexico City': 'Mexico City',
  "Xi'an": "Xi'an",
  'Christchurch': 'Christchurch',
  'Tsim Sha Tsui': 'Tsim Sha Tsui',
  'Marina Bay': 'Marina Bay, Singapore',
  'Mong Kok': 'Mong Kok',
  'Central': 'Central, Hong Kong',
  'Taipei': 'Taipei',
};
// hand-picked Wikimedia Commons landmark photos so each city has a unique cover
const CURATED_CITY_IMG = {
  'ABU DHABI': "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/Abu_dhabi_skylines_2014.jpg/3840px-Abu_dhabi_skylines_2014.jpg",
  'ACCRA': "https://upload.wikimedia.org/wikipedia/commons/0/0a/Acca.jpg",
  'ADDIS ABABA': "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2c/Addis_in_night.jpg/3840px-Addis_in_night.jpg",
  'Alexandria': "https://upload.wikimedia.org/wikipedia/commons/7/79/San_Stefano_Grand_Plaza.JPG",
  'ALGIERS': "https://upload.wikimedia.org/wikipedia/commons/thumb/2/27/Botanical_Garden_Hamma.jpg/3840px-Botanical_Garden_Hamma.jpg",
  'ALMATY': "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/Almaty_city_skyline.jpg/3840px-Almaty_city_skyline.jpg",
  'AMMAN': "https://upload.wikimedia.org/wikipedia/commons/2/24/New_Abdali_2024.png",
  'AMSTERDAM': "https://upload.wikimedia.org/wikipedia/commons/5/57/Imagen_de_los_canales_conc%C3%A9ntricos_en_%C3%81msterdam.png",
  'ANKARA': "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Ankara_from_bus_station.jpg/3840px-Ankara_from_bus_station.jpg",
  'ASUNCIÓN': "https://upload.wikimedia.org/wikipedia/commons/9/9d/Palacio_de_Gobierno2.jpg",
  'ATHENS': "https://upload.wikimedia.org/wikipedia/commons/thumb/7/74/Monastiraki_Square_and_Acropolis_in_Athens_%2844149181684%29.jpg/3840px-Monastiraki_Square_and_Acropolis_in_Athens_%2844149181684%29.jpg",
  'AUCKLAND': "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c9/Auckland_skyline_-_May_2024_%282%29.jpg/3840px-Auckland_skyline_-_May_2024_%282%29.jpg",
  'BAKU': "https://upload.wikimedia.org/wikipedia/commons/4/40/Baku_Montage.jpg",
  'Bangalore': "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/View_from_Visvesvaraya_Industrial_and_Technological_Museum_%282025%29_02.jpg/3840px-View_from_Visvesvaraya_Industrial_and_Technological_Museum_%282025%29_02.jpg",
  'BANGKOK': "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f6/Bangkok%2C_Thailand%2C_High_angle_aerial_view.jpg/3840px-Bangkok%2C_Thailand%2C_High_angle_aerial_view.jpg",
  'Barcelona': "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Aerial_view_of_Barcelona%2C_Spain_%2851227309370%29_edited.jpg/3840px-Aerial_view_of_Barcelona%2C_Spain_%2851227309370%29_edited.jpg",
  'Bath': "https://upload.wikimedia.org/wikipedia/commons/0/08/Roman_Baths_in_Bath_Spa%2C_England_-_July_2006.jpg",
  'BEIJING': "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2d/Skyline_of_Beijing_CBD_with_B-5906_approaching_%2820211016171955%29_%281%29.jpg/3840px-Skyline_of_Beijing_CBD_with_B-5906_approaching_%2820211016171955%29_%281%29.jpg",
  'BEIRUT': "https://upload.wikimedia.org/wikipedia/commons/thumb/c/ce/Beirut_close_to_plane_descent.jpg/3840px-Beirut_close_to_plane_descent.jpg",
  'BELGRADE': "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Panorama_Belgrad.jpg/3840px-Panorama_Belgrad.jpg",
  'BERLIN': "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f7/Museumsinsel_Berlin_Juli_2021_1_%28cropped%29_b.jpg/3840px-Museumsinsel_Berlin_Juli_2021_1_%28cropped%29_b.jpg",
  'BERN': "https://upload.wikimedia.org/wikipedia/commons/4/45/Bundeshaus_Bern_2009%2C_Flooffy.jpg",
  'BOGOTÁ': "https://upload.wikimedia.org/wikipedia/commons/2/20/Bogota%2C_Colombia_%2836668708290%29.jpg",
  'Bordeaux': "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Bordeaux_Place_de_la_Bourse_de_nuit.jpg/3840px-Bordeaux_Place_de_la_Bourse_de_nuit.jpg",
  'BRASÍLIA': "https://upload.wikimedia.org/wikipedia/commons/2/2d/Planalto_Central_%28cropped%29.jpg",
  'BRATISLAVA': "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Slovakia_bratislava.jpg/3840px-Slovakia_bratislava.jpg",
  'BRUSSELS': "https://upload.wikimedia.org/wikipedia/commons/a/ae/Grand_Place_Bruselas_2.jpg",
  'BUCHAREST': "https://upload.wikimedia.org/wikipedia/commons/a/a0/Bucharest_University_Square_%28cropped%29.jpg",
  'BUDAPEST': "https://upload.wikimedia.org/wikipedia/commons/5/53/View_from_Gell%C3%A9rt_Hill_to_the_Danube%2C_Hungary_-_Budapest_%2828493220635%29.jpg",
  'BUENOS AIRES': "https://upload.wikimedia.org/wikipedia/commons/1/1e/Puerto_Madero%2C_Buenos_Aires_%2840689219792%29_%28cropped%29.jpg",
  'Busan': "https://upload.wikimedia.org/wikipedia/commons/thumb/0/05/Gwangandaegyo_Bridge_in_Busan%2C_South_Korea_%28iau2207b%29.tiff/lossy-page1-3840px-Gwangandaegyo_Bridge_in_Busan%2C_South_Korea_%28iau2207b%29.tiff.jpg",
  'CAIRO': "https://upload.wikimedia.org/wikipedia/commons/thumb/7/72/Cairo_Opera_House%2C_Al_Hurriyah_Park_and_the_Nile_river_%2814797782354%29.jpg/3840px-Cairo_Opera_House%2C_Al_Hurriyah_Park_and_the_Nile_river_%2814797782354%29.jpg",
  'CANBERRA': "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cc/Canberra_panorama_from_Mount_Ainslie.jpg/3840px-Canberra_panorama_from_Mount_Ainslie.jpg",
  'Cancun': "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d8/Cancun_Strand_Luftbild_%2822143397586%29.jpg/3840px-Cancun_Strand_Luftbild_%2822143397586%29.jpg",
  'CAPE TOWN': "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/Camps_bay_%2853460319478%29_%28cropped%29.jpg/3840px-Camps_bay_%2853460319478%29_%28cropped%29.jpg",
  'CARACAS': "https://upload.wikimedia.org/wikipedia/commons/b/bd/Caracas_desde_el_%C3%A1vila.jpg",
  'Central': "https://upload.wikimedia.org/wikipedia/commons/0/0e/Hong_Kong_Island_Skyline_2009.jpg",
  'CHENGDU': "https://upload.wikimedia.org/wikipedia/commons/7/74/%E9%9B%AA%E5%B1%B1%E4%B8%8B%E7%9A%84%E6%88%90%E9%83%BD%E5%B8%82%E5%A4%A9%E9%99%85%E7%BA%BF_Chengdu_skyline_with_snow_capped_mountains.jpg",
  'Chiang Mai': "https://upload.wikimedia.org/wikipedia/commons/thumb/8/85/0020-%E0%B8%A7%E0%B8%B1%E0%B8%94%E0%B8%9E%E0%B8%A3%E0%B8%B0%E0%B8%AA%E0%B8%B4%E0%B8%87%E0%B8%AB%E0%B9%8C%E0%B8%A7%E0%B8%A3%E0%B8%A1%E0%B8%AB%E0%B8%B2%E0%B8%A7%E0%B8%B4%E0%B8%AB%E0%B8%B2%E0%B8%A3.jpg/3840px-0020-%E0%B8%A7%E0%B8%B1%E0%B8%94%E0%B8%9E%E0%B8%A3%E0%B8%B0%E0%B8%AA%E0%B8%B4%E0%B8%87%E0%B8%AB%E0%B9%8C%E0%B8%A7%E0%B8%A3%E0%B8%A1%E0%B8%AB%E0%B8%B2%E0%B8%A7%E0%B8%B4%E0%B8%AB%E0%B8%B2%E0%B8%A3.jpg",
  'Chicago': "https://upload.wikimedia.org/wikipedia/commons/a/a5/Chicago_River_ferry_b.jpg",
  'Chinatown': "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Aerial_perspective_of_Singapore%27s_Chinatown.jpg/3840px-Aerial_perspective_of_Singapore%27s_Chinatown.jpg",
  'CHONGQING': "https://upload.wikimedia.org/wikipedia/commons/6/67/Chongqing_Nightscape.jpg",
  'COPENHAGEN': "https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/2018_-_Christiansborg_from_the_Marble_Bridge.jpg/3840px-2018_-_Christiansborg_from_the_Marble_Bridge.jpg",
  'Córdoba': "https://upload.wikimedia.org/wikipedia/commons/4/42/Panorama_Nueva_C%C3%B3rdoba_2012-02-03.jpg",
  'DAKAR': "https://upload.wikimedia.org/wikipedia/commons/9/96/Dakar-place-de-l%27Ind%C3%A9pendance.jpg",
  'Delhi': "https://upload.wikimedia.org/wikipedia/commons/4/40/Jama_Masjid_2011.jpg",
  'DUBAI': "https://upload.wikimedia.org/wikipedia/en/thumb/c/c7/Burj_Khalifa_2021.jpg/3840px-Burj_Khalifa_2021.jpg",
  'DUBLIN': "https://upload.wikimedia.org/wikipedia/commons/thumb/9/92/Dublin_-_aerial_-_2025-07-07_01.jpg/3840px-Dublin_-_aerial_-_2025-07-07_01.jpg",
  'Durban': "https://upload.wikimedia.org/wikipedia/commons/thumb/9/90/Durban_from_the_Balcony_%28Dennis_Sylvester_Hurd%29_1.jpg/3840px-Durban_from_the_Balcony_%28Dennis_Sylvester_Hurd%29_1.jpg",
  'Edinburgh': "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/Skyline_of_Edinburgh.jpg/3840px-Skyline_of_Edinburgh.jpg",
  'Faro': "https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/2021_12_12_arne_mueseler_08_17_0576.jpg/3840px-2021_12_12_arne_mueseler_08_17_0576.jpg",
  'Fukuoka': "https://upload.wikimedia.org/wikipedia/commons/b/bd/Fukuoka_Skyline_of_Seaside_Momochi.jpg",
  'Gangneung': "https://upload.wikimedia.org/wikipedia/commons/4/4d/Jumunjin_Lighthouse_20220501_026.jpg",
  'Guadalajara': "https://upload.wikimedia.org/wikipedia/commons/3/38/Panor%C3%A1mica_Guadalajara_desde_edificio_Bansi_hacia_norte_%28cropped%29.jpg",
  'GUANGZHOU': "https://upload.wikimedia.org/wikipedia/commons/thumb/2/24/Canton_Tower_20241027.jpg/3840px-Canton_Tower_20241027.jpg",
  'Gyeongju': "https://upload.wikimedia.org/wikipedia/commons/2/24/Gyeongju_montage.png",
  'Hamburg': "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Hamburg%2C_Landungsbr%C3%BCcken_--_2016_--_3131-7.jpg/3840px-Hamburg%2C_Landungsbr%C3%BCcken_--_2016_--_3131-7.jpg",
  'HANOI': "https://upload.wikimedia.org/wikipedia/commons/8/8e/Hanoi_skyline_with_Ba_Vi_Mountain.jpg",
  'HAVANA': "https://upload.wikimedia.org/wikipedia/commons/1/12/DJI_0197_crp_wiki.jpg",
  'HELSINKI': "https://upload.wikimedia.org/wikipedia/commons/3/3e/Suomenlinna_%28cropped%29.jpg",
  'HONG KONG': "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Flag_of_Hong_Kong.svg/960px-Flag_of_Hong_Kong.svg.png",
  'ISLAMABAD': "https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/Faisal_Mosque%2C_Islamabad_III.jpg/3840px-Faisal_Mosque%2C_Islamabad_III.jpg",
  'ISTANBUL': "https://upload.wikimedia.org/wikipedia/commons/c/cb/Historical_peninsula_and_modern_skyline_of_Istanbul.jpg",
  'Izmir': "https://upload.wikimedia.org/wikipedia/commons/5/50/Cumhuriyet_Square.jpg",
  'JAKARTA': "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2d/Bundaran_Hotel_Indonesia_%282025%29.jpg/3840px-Bundaran_Hotel_Indonesia_%282025%29.jpg",
  'Jeju': "https://upload.wikimedia.org/wikipedia/commons/c/c6/Jeju_Island.jpg",
  'Jeonju': "https://upload.wikimedia.org/wikipedia/commons/e/ed/Jeonju_Hanok_Maeul_01.jpg",
  'Johannesburg': "https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Johannesburg_skyline_2017.jpg/3840px-Johannesburg_skyline_2017.jpg",
  'Kaohsiung': "https://upload.wikimedia.org/wikipedia/commons/1/1d/Kaohsiung_Skyline_2020_%28cropped%29.jpg",
  'KUALA LUMPUR': "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/Bukit_Bintang_junction_in_2024_2.jpg/3840px-Bukit_Bintang_junction_in_2024_2.jpg",
  'KYIV': "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/P1130119-1.JPG/3840px-P1130119-1.JPG",
  'Kyoto': "https://upload.wikimedia.org/wikipedia/commons/3/3c/Kiyomizu.jpg",
  'LA PAZ': "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Vista_del_centro_de_La_Paz.jpg/3840px-Vista_del_centro_de_La_Paz.jpg",
  'LAGOS': "https://upload.wikimedia.org/wikipedia/commons/f/f4/Tafa_Balewa_Square_%28Onikan%29_in_Lagos._Nigeria.jpg",
  'LIMA': "https://upload.wikimedia.org/wikipedia/commons/6/69/Bas%C3%ADlica_Catedral_Metropolitana_de_Lima_%28cropped%29.jpg",
  'LISBON': "https://upload.wikimedia.org/wikipedia/commons/f/f2/Lisboa_-_Portugal_%2852597836992%29.jpg",
  'Liverpool': "https://upload.wikimedia.org/wikipedia/commons/7/78/Pier_Head%2C_Liverpool_-_geograph.org.uk_-_3059094.jpg",
  'LJUBLJANA': "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c7/Ljubljana_Old_Town%2C_Slovenia_%28Old_Camera%29_%2833286165680%29.jpg/3840px-Ljubljana_Old_Town%2C_Slovenia_%28Old_Camera%29_%2833286165680%29.jpg",
  'LONDON': "https://upload.wikimedia.org/wikipedia/commons/6/67/London_Skyline_%28125508655%29.jpeg",
  'LOS ANGELES': "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5a/Hollywood_Sign_%28Zuschnitt%29.jpg/3840px-Hollywood_Sign_%28Zuschnitt%29.jpg",
  'LUXEMBOURG': "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Flag_of_Luxembourg.svg/960px-Flag_of_Luxembourg.svg.png",
  'Luxor': "https://upload.wikimedia.org/wikipedia/commons/3/35/LuxorHotelsIbnWalidSt.jpg",
  'Lyon': "https://upload.wikimedia.org/wikipedia/commons/thumb/9/97/Lyon-part-dieu-2023.jpg/3840px-Lyon-part-dieu-2023.jpg",
  'MADRID': "https://upload.wikimedia.org/wikipedia/commons/thumb/1/14/Madrid_-_Sky_Bar_360%C2%BA_%28Hotel_Riu_Plaza_Espa%C3%B1a%29%2C_vistas_19.jpg/3840px-Madrid_-_Sky_Bar_360%C2%BA_%28Hotel_Riu_Plaza_Espa%C3%B1a%29%2C_vistas_19.jpg",
  'MANILA': "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f7/Cityscape_of_Manila%2C_2025_%2801%29.jpg/3840px-Cityscape_of_Manila%2C_2025_%2801%29.jpg",
  'Marina Bay': "https://upload.wikimedia.org/wikipedia/commons/thumb/1/16/Marina_Bay_Singapore-3499.jpg/3840px-Marina_Bay_Singapore-3499.jpg",
  'MARRAKECH': "https://upload.wikimedia.org/wikipedia/commons/9/9c/Pavillon_Menarag%C3%A4rten.jpg",
  'Marseille': "https://upload.wikimedia.org/wikipedia/commons/a/a1/Notre-Dame_de_la_Garde_aerial_view_2020.jpeg",
  'Melbourne': "https://upload.wikimedia.org/wikipedia/commons/7/74/Melbourne_skyline_sor.jpg",
  'MEXICO CITY': "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/Sobrevuelos_CDMX_HJ2A4913_%2825514321687%29_%28cropped%29.jpg/3840px-Sobrevuelos_CDMX_HJ2A4913_%2825514321687%29_%28cropped%29.jpg",
  'Milan': "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Milan_-_Scala_-_Facade.jpg/3840px-Milan_-_Scala_-_Facade.jpg",
  'MINSK': "https://upload.wikimedia.org/wikipedia/commons/thumb/4/40/View_of_Minsk_%28180819%29_09.jpg/3840px-View_of_Minsk_%28180819%29_09.jpg",
  'Mong Kok': "https://upload.wikimedia.org/wikipedia/commons/c/c5/Sai_Yeung_Choi_Street_South_2008_Night.jpg",
  'MONTEVIDEO': "https://upload.wikimedia.org/wikipedia/commons/5/59/PALACIO_LEGISLATIVO_01.JPG",
  'MOSCOW': "https://upload.wikimedia.org/wikipedia/commons/8/85/Saint_Basil%27s_Cathedral_and_the_Red_Square.jpg",
  'MUMBAI': "https://upload.wikimedia.org/wikipedia/commons/2/2b/Mumbai_Bandra-Worli_Sea_Link.jpg",
  'Munich': "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d3/Stadtbild_M%C3%BCnchen.jpg/3840px-Stadtbild_M%C3%BCnchen.jpg",
  'MUSCAT': "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Al_Alam_Palace.jpg/3840px-Al_Alam_Palace.jpg",
  'NAIROBI': "https://upload.wikimedia.org/wikipedia/commons/thumb/b/be/Nairobi_skyline_from_Gem_Hotel.jpg/3840px-Nairobi_skyline_from_Gem_Hotel.jpg",
  'Naples': "https://upload.wikimedia.org/wikipedia/commons/thumb/8/88/Napoli_-_Maschio_Angioino_-_202209302342_3.jpg/3840px-Napoli_-_Maschio_Angioino_-_202209302342_3.jpg",
  'NEW DELHI': "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fe/Forecourt%2C_Rashtrapati_Bhavan_-_1.jpg/3840px-Forecourt%2C_Rashtrapati_Bhavan_-_1.jpg",
  'New Orleans': "https://upload.wikimedia.org/wikipedia/commons/c/cb/New_Orleans_from_the_Air_September_2019_-_Central_Business_District_Skyline_%28cropped%29.jpg",
  'NEW YORK': "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7a/View_of_Empire_State_Building_from_Rockefeller_Center_New_York_City_dllu_%28cropped%29.jpg/3840px-View_of_Empire_State_Building_from_Rockefeller_Center_New_York_City_dllu_%28cropped%29.jpg",
  'Osaka': "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/Osaka_Castle_02bs3200.jpg/3840px-Osaka_Castle_02bs3200.jpg",
  'OSLO': "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Nationaltheatret_evening.jpg/3840px-Nationaltheatret_evening.jpg",
  'OTTAWA': "https://upload.wikimedia.org/wikipedia/commons/2/22/Parliament-Ottawa.jpg",
  'Oxford': "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3e/Museum_of_Oxford_%285652685943%29.jpg/3840px-Museum_of_Oxford_%285652685943%29.jpg",
  'PANAMA CITY': "https://upload.wikimedia.org/wikipedia/commons/0/02/Panama_Papers_%28148830809%29.jpeg",
  'PARIS': "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/La_Tour_Eiffel_vue_de_la_Tour_Saint-Jacques%2C_Paris_ao%C3%BBt_2014_%282%29.jpg/3840px-La_Tour_Eiffel_vue_de_la_Tour_Saint-Jacques%2C_Paris_ao%C3%BBt_2014_%282%29.jpg",
  'Perth': "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3e/Perth_CBD_skyline_from_State_War_Memorial_Lookout%2C_2023%2C_04_b.jpg/3840px-Perth_CBD_skyline_from_State_War_Memorial_Lookout%2C_2023%2C_04_b.jpg",
  'Phuket': "https://upload.wikimedia.org/wikipedia/commons/thumb/6/60/Phuket_Aerial.jpg/3840px-Phuket_Aerial.jpg",
  'Porto': "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/Puente_Don_Luis_I%2C_Oporto%2C_Portugal%2C_2012-05-09%2C_DD_13.JPG/3840px-Puente_Don_Luis_I%2C_Oporto%2C_Portugal%2C_2012-05-09%2C_DD_13.JPG",
  'PRAGUE': "https://upload.wikimedia.org/wikipedia/commons/a/a7/Prague_%286365119737%29.jpg",
  'Provence': "https://upload.wikimedia.org/wikipedia/commons/0/03/Lavender_field_and_Mont_Ventoux.jpg",
  'Pyeongchang': "https://upload.wikimedia.org/wikipedia/commons/thumb/8/82/Alpensia_20170202_04_%2832506763362%29.jpg/3840px-Alpensia_20170202_04_%2832506763362%29.jpg",
  'Queenstown': "https://upload.wikimedia.org/wikipedia/commons/c/c9/Queenstown_1_%288168013172%29.jpg",
  'QUITO': "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/FACHADA_ASAMBLEA_NACIONAL._QUITO%2C_20_DE_FEBRERO_2020._01.jpg/3840px-FACHADA_ASAMBLEA_NACIONAL._QUITO%2C_20_DE_FEBRERO_2020._01.jpg",
  'REYKJAVÍK': "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/Reykjav%C3%ADk%2C_view_from_Hallgr%C3%ADmskirkja_%282%29.jpg/3840px-Reykjav%C3%ADk%2C_view_from_Hallgr%C3%ADmskirkja_%282%29.jpg",
  'RIGA': "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Riga_%2833844464828%29.jpg/3840px-Riga_%2833844464828%29.jpg",
  'RIO DE JANEIRO': "https://upload.wikimedia.org/wikipedia/commons/9/98/Cidade_Maravilhosa.jpg",
  'ROME': "https://upload.wikimedia.org/wikipedia/commons/7/7e/Trevi_Fountain%2C_Rome%2C_Italy_2_-_May_2007.jpg",
  'Rotterdam': "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e3/A_view_of_Rotterdam%2C_taken_from_the_roof_of_the_Maassilo%2C_Rotterdam%2C_The_Netherlands.jpg/3840px-A_view_of_Rotterdam%2C_taken_from_the_roof_of_the_Maassilo%2C_Rotterdam%2C_The_Netherlands.jpg",
  'Salvador': "https://upload.wikimedia.org/wikipedia/commons/c/c0/Salvador_BA_%28cropped%29_2.jpg",
  'SAN FRANCISCO': "https://upload.wikimedia.org/wikipedia/commons/f/f9/San_Francisco_Downtown_Aerial%2C_August_2025.jpg",
  'SANTIAGO': "https://upload.wikimedia.org/wikipedia/commons/thumb/2/21/Palacio_de_La_Moneda_-_miguelreflex.jpg/3840px-Palacio_de_La_Moneda_-_miguelreflex.jpg",
  'Santorini': "https://upload.wikimedia.org/wikipedia/commons/b/bf/2011_Dimos_Thiras.png",
  'São Paulo': "https://upload.wikimedia.org/wikipedia/commons/7/73/Marginal_Pinheiros_e_Jockey_Club.jpg",
  'Sapporo': "https://upload.wikimedia.org/wikipedia/commons/5/54/SapporoCity_Skylines2020.jpg",
  'Sentosa': "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c3/1_sentosa_aerial_2016.jpg/3840px-1_sentosa_aerial_2016.jpg",
  'SEOUL': "https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/%EC%A4%91%ED%99%94%EC%A0%84%EC%9D%98_%EB%82%AE.jpg/3840px-%EC%A4%91%ED%99%94%EC%A0%84%EC%9D%98_%EB%82%AE.jpg",
  'Seville': "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2b/Sevilla_Cathedral_-_Southeast.jpg/3840px-Sevilla_Cathedral_-_Southeast.jpg",
  'SHANGHAI': "https://upload.wikimedia.org/wikipedia/commons/4/4c/Huangpu_Park_20124-Shanghai_%2832208802494%29.jpg",
  'Shenyang': "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/%E6%B2%88%E9%98%B3%E9%9D%92%E5%B9%B4%E5%85%AC%E5%9B%AD.jpg/3840px-%E6%B2%88%E9%98%B3%E9%9D%92%E5%B9%B4%E5%85%AC%E5%9B%AD.jpg",
  'SHENZHEN': "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e3/Commercial_area_of_futian_to_east2020.jpg/3840px-Commercial_area_of_futian_to_east2020.jpg",
  'SINGAPORE': "https://upload.wikimedia.org/wikipedia/commons/thumb/4/48/Flag_of_Singapore.svg/960px-Flag_of_Singapore.svg.png",
  'SOFIA': "https://upload.wikimedia.org/wikipedia/commons/b/be/Russian_church_%2837591925970%29.jpg",
  'St Petersburg': "https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Spb_06-2017_img20_StMichael_Castle_%28cropped%29.jpg/3840px-Spb_06-2017_img20_StMichael_Castle_%28cropped%29.jpg",
  'STOCKHOLM': "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/Royal_Dramatic_Theatre_Stockholm.jpg/3840px-Royal_Dramatic_Theatre_Stockholm.jpg",
  'SUZHOU': "https://upload.wikimedia.org/wikipedia/commons/8/8f/%E4%B8%9C%E6%96%B9%E4%B9%8B%E9%97%A81.jpg",
  'SYDNEY': "https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Sydney_Opera_House_and_Harbour_Bridge_Dusk_%282%29_2019-06-21.jpg/3840px-Sydney_Opera_House_and_Harbour_Bridge_Dusk_%282%29_2019-06-21.jpg",
  'Tainan': "https://upload.wikimedia.org/wikipedia/commons/8/8c/Downtown_Tainan%282012%29_%28cropped%29.jpg",
  'TAIPEI': "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ea/Taipei_Skyline_2022.06.29.jpg/3840px-Taipei_Skyline_2022.06.29.jpg",
  'TALLINN': "https://upload.wikimedia.org/wikipedia/commons/thumb/7/70/Raekoja_plats_at_night.jpg/3840px-Raekoja_plats_at_night.jpg",
  'TASHKENT': "https://upload.wikimedia.org/wikipedia/en/f/f6/Nest_One_Tashkent.jpg",
  'TBILISI': "https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/View_of_Tbilisi_from_Tabori_Church_2023-10-08-2.jpg/3840px-View_of_Tbilisi_from_Tabori_Church_2023-10-08-2.jpg",
  'TEHRAN': "https://upload.wikimedia.org/wikipedia/commons/a/ae/North_of_Tehran_Skyline_view.jpg",
  'TEL AVIV': "https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/Sarona_CBD_01_%28cropped%29.jpg/3840px-Sarona_CBD_01_%28cropped%29.jpg",
  'Thessaloniki': "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b8/Thessaloniki_Heptapyrgion_northeastern_wall_from_the_inner_yard.jpg/3840px-Thessaloniki_Heptapyrgion_northeastern_wall_from_the_inner_yard.jpg",
  'TOKYO': "https://upload.wikimedia.org/wikipedia/commons/b/b2/Skyscrapers_of_Shinjuku_2009_January.jpg",
  'TORONTO': "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/Toronto_Skyline_from_Snake_Island%2C_February_28_2026_%2808%29.jpg/3840px-Toronto_Skyline_from_Snake_Island%2C_February_28_2026_%2808%29.jpg",
  'Tsim Sha Tsui': "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/Vista_del_Puerto_de_Victoria_desde_Sky100%2C_Hong_Kong%2C_2013-08-09%2C_DD_10.JPG/3840px-Vista_del_Puerto_de_Victoria_desde_Sky100%2C_Hong_Kong%2C_2013-08-09%2C_DD_10.JPG",
  'TUNIS': "https://upload.wikimedia.org/wikipedia/commons/thumb/2/22/Minaret_et_patio_de_la_mosqu%C3%A9e_Zitouna_au_centre_de_la_M%C3%A9dina_de_Tunis.jpg/3840px-Minaret_et_patio_de_la_mosqu%C3%A9e_Zitouna_au_centre_de_la_M%C3%A9dina_de_Tunis.jpg",
  'Uluru': "https://upload.wikimedia.org/wikipedia/commons/a/a8/ULURU.jpg",
  'Ushuaia': "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a4/Ushuaia_aerial_panorama.jpg/3840px-Ushuaia_aerial_panorama.jpg",
  'Utrecht': "https://upload.wikimedia.org/wikipedia/commons/1/14/Sol_Lumen.jpg",
  'VALLETTA': "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b7/St_Sebastian_Curtain_%28cropped%29.jpg/3840px-St_Sebastian_Curtain_%28cropped%29.jpg",
  'Venice': "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/Venezia_aerial_view.jpg/3840px-Venezia_aerial_view.jpg",
  'VIENNA': "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Schoenbrunn_philharmoniker_2012.jpg/3840px-Schoenbrunn_philharmoniker_2012.jpg",
  'VILNIUS': "https://upload.wikimedia.org/wikipedia/en/8/84/The_White_Bridge_and_%C5%A0nipi%C5%A1k%C4%97s_district_in_Vilnius_in_2023_by_Augustas_Did%C5%BEgalvis.jpg",
  'Vladivostok': "https://upload.wikimedia.org/wikipedia/commons/6/62/Vladivostok._GUM_Department_Store_P8070703_2200.jpg",
  'WARSAW': "https://upload.wikimedia.org/wikipedia/commons/3/35/Aleja_Niepdleglosci_Warsaw_2022_aerial_%28cropped%29.jpg",
  'WASHINGTON, D.C.': "https://upload.wikimedia.org/wikipedia/commons/e/e4/12-07-13-washington-by-RalfR-08.jpg",
  'Wellington': "https://upload.wikimedia.org/wikipedia/commons/3/32/Seddon_Statue_in_Parliament_Grounds.jpg",
  'YEREVAN': "https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/Mount_Ararat_and_the_Yerevan_skyline_%28June_2018%29.jpg/3840px-Mount_Ararat_and_the_Yerevan_skyline_%28June_2018%29.jpg",
  'ZAGREB': "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/Zagreb_%2829255640143%29.jpg/3840px-Zagreb_%2829255640143%29.jpg",
  'ZURICH': "https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Altstadt_Z%C3%BCrich_2015.jpg/3840px-Altstadt_Z%C3%BCrich_2015.jpg",
};
function wikiTitleFor(name) {
  return WIKI_TITLE_OVERRIDES[name] || name;
}
function useWikiImage(name) {
  // case-insensitive curated lookup so 'BEIJING' finds 'Beijing'
  const lookup = (n) => {
    if (!n) return null;
    if (CURATED_CITY_IMG[n]) return CURATED_CITY_IMG[n];
    const lc = n.toLowerCase();
    for (const k of Object.keys(CURATED_CITY_IMG)) {
      if (k.toLowerCase() === lc) return CURATED_CITY_IMG[k];
    }
    return null;
  };
  const curated = lookup(name);
  const cached = curated || WIKI_CACHE[name];
  const [src, setSrc] = useState(cached || '');
  useEffect(() => {
    if (curated || cached) return;
    let cancelled = false;
    const title = encodeURIComponent(wikiTitleFor(name));
    fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${title}`)
      .then(r => r.ok ? r.json() : null)
      .then(j => {
        if (cancelled || !j) return;
        let url = j.originalimage?.source || j.thumbnail?.source;
        if (!url) return;
        // upgrade common thumb width to 800
        url = url.replace(/\/(\d{2,4})px-/, '/800px-');
        WIKI_CACHE[name] = url;
        try { localStorage.setItem('cony.wikiCache', JSON.stringify(WIKI_CACHE)); } catch {}
        setSrc(url);
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, [name]); // eslint-disable-line
  return src;
}

/* ================= city deck (Tinder-style + Airbnb cards) ================= */
function CityDeck({ cities, openId, currentId, onChange, onTravel, balance, countryId, onEarn, onPayslip, pet }) {
  const idx = Math.max(0, cities.findIndex(c => c.id === openId));
  const total = cities.length;
  const [drag, setDrag] = useState({ x: 0, dragging: false });
  const startRef = useRef(null);

  const advance = () => { if (idx < total - 1) onChange(cities[idx + 1].id); };
  const rewind  = () => { if (idx > 0)         onChange(cities[idx - 1].id); };

  const onDown = (e) => {
    if (e.target.closest('.media-card, button, input, label, select, .domestic-bar, [data-no-drag]')) return;
    e.currentTarget.setPointerCapture?.(e.pointerId);
    startRef.current = { x: e.clientX, y: e.clientY };
    setDrag({ x: 0, dragging: true });
  };
  const onMove = (e) => {
    if (!startRef.current) return;
    setDrag({ x: e.clientX - startRef.current.x, dragging: true });
  };
  const onUp = () => {
    if (!startRef.current) return;
    startRef.current = null;
    if (drag.x < -90 && idx < total - 1) advance();
    else if (drag.x > 90 && idx > 0) rewind();
    setDrag({ x: 0, dragging: false });
  };

  const stack = [];
  for (let d = Math.min(2, total - idx - 1); d >= 0; d--) {
    stack.push({ city: cities[idx + d], depth: d });
  }
  const fromCity = cities.find(c => c.id === currentId);

  return (
    <div className="city-deck">
      <div className="deck-stack">
        {stack.map(({ city, depth }) => {
          const isTop = depth === 0;
          const dyn = isTop && drag.dragging
            ? { transform: `translate3d(${drag.x}px, 0, 0) rotate(${drag.x * 0.05}deg)`, transition: 'none' }
            : {};
          return (
            <div key={city.id} className={`city-card depth-${depth}`} style={dyn}>
              <CityCard
                city={city}
                isCurrent={city.id === currentId}
                fromCity={fromCity}
                onTravel={onTravel}
                balance={balance}
                countryId={countryId}
                onEarn={onEarn}
                onPayslip={onPayslip}
                pet={pet}
                onPointerDown={isTop ? onDown : undefined}
                onPointerMove={isTop ? onMove : undefined}
                onPointerUp={isTop ? onUp : undefined}
                onPointerCancel={isTop ? onUp : undefined}
              />
            </div>
          );
        })}
      </div>
      <div className="deck-controls">
        <button className="deck-btn" disabled={idx === 0} onClick={rewind} aria-label="上一张">
          <svg viewBox="0 0 24 24" width="18" height="18"><path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
        </button>
        <div className="deck-count">{idx + 1}<span className="dim"> / {total}</span></div>
        <button className="deck-btn primary" disabled={idx === total - 1} onClick={advance} aria-label="下一张">
          <svg viewBox="0 0 24 24" width="18" height="18"><path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
        </button>
      </div>
    </div>
  );
}

function CityCard({ city, isCurrent, fromCity, onTravel, balance, countryId, onEarn, onPayslip, pet, onPointerDown, onPointerMove, onPointerUp, onPointerCancel }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const ccTitle = lang === 'zh' ? (city.name_zh || city.name) : (city.name || city.name_zh);
  const initial = (city.name_zh || city.name || '?').charAt(0);
  // city image: wiki first, fallback LoremFlickr
  const wikiImg = useWikiImage(city.name);
  const seed = encodeURIComponent(`${city.name},landmark,cityscape`);
  const fallbackImg = `https://loremflickr.com/800/440/${seed}?lock=${encodeHash(city.id)}`;
  const heroImg = wikiImg || fallbackImg;
  const seasonal = seasonalFor(city);
  return (
    <div className="cc-inner">
      <div className={`cc-hero ${seasonal ? 'season-' + seasonal.tone : ''}`}
           style={{ backgroundImage: `url(${heroImg})` }}
           onPointerDown={onPointerDown}
           onPointerMove={onPointerMove}
           onPointerUp={onPointerUp}
           onPointerCancel={onPointerCancel}>
        <div className="cc-hero-tint" aria-hidden="true"></div>
        <div className="cc-hero-watermark" aria-hidden="true">{initial}</div>
        {(() => {
          const ll = latLonOf(city, countryId);
          if (!ll) return null;
          return <WeatherPill lat={ll[0]} lon={ll[1]}/>;
        })()}
        {seasonal && (
          <div className={`seasonal-badge tone-${seasonal.tone}`} title={seasonal.tip}>
            <span className="sb-emoji">{seasonal.emoji}</span>
            <span className="sb-label">{seasonal.label}</span>
          </div>
        )}
        <div className="cc-hero-meta">
          <div className="cc-tag">{city.primary ? T('city.capital') : T('city.city')} · {city.name}</div>
          <div className="cc-name">{ccTitle}</div>
          {isCurrent && <div className="cc-here">{T('app.now')}</div>}
        </div>
      </div>
      {!isCurrent && fromCity && (
        <DomesticTravelBar from={fromCity} to={city} onDepart={onTravel} balance={balance} countryId={countryId} pet={pet}/>
      )}
      <div className="cc-content" data-no-drag>
        <div className="cc-brief">{(() => {
          if (!city.brief) return '';
          if (lang === 'zh') return city.brief;
          // Language-specific lookup → English fallback → original Chinese
          const dictName = 'CITY_BRIEFS_' + lang.toUpperCase();
          const dict = window[dictName];
          if (dict && dict[city.brief]) return dict[city.brief];
          if (window.CITY_BRIEFS_EN && window.CITY_BRIEFS_EN[city.brief]) return window.CITY_BRIEFS_EN[city.brief];
          return city.brief;
        })()}</div>
        {(() => {
          // Cultural works: prefer subCity media, fall back to country media when empty
          let mediaList = (city.media && city.media.length > 0) ? city.media : [];
          if (mediaList.length === 0 && countryId && window.COUNTRY_MEDIA) {
            mediaList = window.COUNTRY_MEDIA[countryId] || [];
          }
          // Split cultural works vs food/cuisine.
          // (Earlier we showed a "300+ chars → $1000" banner here, but with the
          // city brief + WORKING HOLIDAY card + culture grid + cuisine grid all
          // stacking on one card the banner pushed the layout into mush. The
          // same offer is already visible as a chip on the "✍️ Write"
          // button inside MediaDetailModal, so the banner was redundant.)
          const cultural = mediaList.filter(m => m.type !== 'FOOD');
          const cuisine  = mediaList.filter(m => m.type === 'FOOD');
          return (
            <>
              {cultural.length > 0 && (
                <>
                  <div className="cc-section-title">{T('section.media')}</div>
                  <div className="cc-media cultural-media">
                    {cultural.map((m, i) => <MediaCard key={i} m={m}/>)}
                  </div>
                </>
              )}
              {cuisine.length > 0 && (
                <>
                  <div className="cc-section-title cuisine-title">{T('mtype.FOOD') || 'Cuisine'}</div>
                  <div className="cc-media cuisine-media">
                    {cuisine.map((m, i) => <MediaCard key={`f${i}`} m={m}/>)}
                  </div>
                </>
              )}
            </>
          );
        })()}
        <MessageBoard city={city}/>
      </div>
      {isCurrent && city.jobs && city.jobs.length > 0 && onEarn && (
        <JobBoard city={city} balance={balance} onEarn={onEarn} onPayslip={onPayslip} pet={pet}/>
      )}
    </div>
  );
}

function DomesticTravelBar({ from, to, onDepart, balance = 0, countryId, pet }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const cityLbl = (c) => lang === 'zh' ? (c.name_zh || c.name) : (c.name || c.name_zh);
  const [tid, setTid] = useState('subway');
  const cabins = (window.CABIN_CLASSES && window.CABIN_CLASSES[tid]) || [];
  const [cabinId, setCabinId] = useState('economy');
  useEffect(() => {
    if (cabins.length && !cabins.some(c => c.id === cabinId)) {
      setCabinId(cabins[0].id);
    }
  }, [tid]); // eslint-disable-line
  return (
    <div className="domestic-bar" data-no-drag>
      <div className="db-from">{T('depart.from')} <strong>{cityLbl(from)}</strong></div>
      <div className="db-transports">
        {window.TRANSPORTS.map(t => (
          <button key={t.id} type="button"
                  className={`db-pill ${tid === t.id ? 'active' : ''}`}
                  onClick={() => setTid(t.id)}>
            {T(`trans.${t.id}`)}
          </button>
        ))}
      </div>
      {cabins.length > 1 && (
        <div className="db-classes">
          {cabins.map(c => (
            <button key={c.id} type="button"
                    className={`db-class ${cabinId === c.id ? 'active' : ''}`}
                    onClick={() => setCabinId(c.id)}
                    title={lang === 'zh' ? c.hint : (window.CABIN_HINT_I18N?.[lang]?.[c.id] || c.hint)}>
              {lang === 'zh' ? c.name_zh : (c.name || c.name_zh)}
            </button>
          ))}
        </div>
      )}
      {(() => {
        const tr = window.TRANSPORTS.find(t => t.id === tid);
        const cost = tr && window.calcDomesticCost ? window.calcDomesticCost(from, to, tr, cabinId, countryId) : 0;
        const petFee = (pet && window.calcPetFee) ? window.calcPetFee(tid, cost) : 0;
        const total = cost + petFee;
        const cur = window.COUNTRY_CURRENCY?.[countryId];
        const localText = (cur && cur.code !== 'USD')
          ? ` · ${cur.symbol}${cur.decimals === 0 ? Math.round(total * cur.perUSD).toLocaleString() : (total * cur.perUSD).toFixed(cur.decimals)}`
          : '';
        const insufficient = total > balance;
        return (
          <button className={`db-depart ${insufficient ? 'insufficient' : ''}`}
                  type="button"
                  disabled={insufficient}
                  onClick={() => { if (!insufficient) onDepart(to.id, tid, cabinId); }}>
            <span className="dbd-label">{insufficient ? T('common.error') : `${T('btn.depart')} → ${cityLbl(to)}`}</span>
            <span className="dbd-cost">
              ${total}{localText}
              {petFee > 0 && <span className="dbd-pet">{T('pet.title')} +${petFee}</span>}
            </span>
          </button>
        );
      })()}
    </div>
  );
}

/* ================= city tasks (3 per city · reward on complete) ================= */
function CityTasks({ city, onReward }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const L = lang === 'zh' ? {
    song:['听一首本地歌曲','点开任意 SONG 卡'],
    film:['查阅本地电影简介','点开任意 FILM 卡'],
    food:['尝一口本地味道','点开任意 FOOD 卡'],
    book:['读一段本地文学','展开任意 BOOK 卡'],
    head:'城市任务', reward:'已奖励',
  } : {
    song:['Listen to a local song','tap any SONG card'],
    film:['Read up on a local film','tap any FILM card'],
    food:['Try a local taste','tap any FOOD card'],
    book:['Read a local passage','open any BOOK card'],
    head:'City tasks', reward:'reward claimed',
  };
  const types = new Set((city.media || []).map(m => m.type));
  const tasks = [];
  if (types.has('SONG') || types.has('MUSICAL')) tasks.push({ id:'song', label:L.song[0], hint:L.song[1] });
  if (types.has('FILM') || types.has('ANIME'))   tasks.push({ id:'film', label:L.film[0], hint:L.film[1] });
  if (types.has('FOOD'))                          tasks.push({ id:'food', label:L.food[0], hint:L.food[1] });
  if (types.has('BOOK'))                          tasks.push({ id:'book', label:L.book[0], hint:L.book[1] });
  if (tasks.length === 0) return null;

  const storeKey = `cony.tasks.${city.id}`;
  const [done, setDone] = useState(() => {
    try { return JSON.parse(localStorage.getItem(storeKey) || '{}'); } catch { return {}; }
  });
  const [rewardClaimed, setRewardClaimed] = useState(() => done.__rewardClaimed === true);

  // listen for the global media-tap event so completion is automatic
  useEffect(() => {
    const onMediaTap = (ev) => {
      const t = ev.detail?.type;
      const mapping = { SONG:'song', MUSICAL:'song', FILM:'film', ANIME:'film', FOOD:'food', BOOK:'book' };
      const key = mapping[t];
      if (!key) return;
      setDone(prev => {
        if (prev[key]) return prev;
        const next = { ...prev, [key]: true };
        try { localStorage.setItem(storeKey, JSON.stringify(next)); } catch {}
        return next;
      });
    };
    window.addEventListener('cony-media-tap', onMediaTap);
    return () => window.removeEventListener('cony-media-tap', onMediaTap);
  }, [storeKey]);

  const completed = tasks.filter(t => done[t.id]).length;
  const allDone = completed === tasks.length;
  useEffect(() => {
    if (allDone && !rewardClaimed) {
      const next = { ...done, __rewardClaimed: true };
      try { localStorage.setItem(storeKey, JSON.stringify(next)); } catch {}
      setRewardClaimed(true);
      onReward?.(150);
    }
  }, [allDone, rewardClaimed]); // eslint-disable-line

  return (
    <div className="city-tasks" data-no-drag>
      <div className="ct-head">
        <span className="ct-tag">{L.head}</span>
        <span className="ct-progress">{completed} / {tasks.length}{rewardClaimed ? ` · ✓ ${L.reward} $150` : ''}</span>
      </div>
      <div className="ct-list">
        {tasks.map(t => (
          <div key={t.id} className={`ct-row ${done[t.id] ? 'done' : ''}`}>
            <span className="ct-mark">{done[t.id] ? '✓' : '○'}</span>
            <span className="ct-label">{t.label}</span>
            <span className="ct-hint">{t.hint}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

/* ================= street encounters (1 per session per city) ================= */
// 各城专属偶遇 — 文化局部化
const CITY_ENCOUNTERS = {
  'jp-tokyo': [
    { title:'涩谷十字路口', text:()=> '绿灯亮起，几千人同时迈步。一对外国情侣冲你递相机：「能帮我们拍一张吗？」',
      choices:[
        { label:'认真选角度连拍 5 张', tip:'他们感动得请你吃了一个珍珠奶茶。', reward: 35 },
        { label:'快门按一下就还',     tip:'他们说谢谢，转身就走。', reward: 0 },
        { label:'蹭进合影',           tip:'你成了他们旅行相册的彩蛋。', reward: 15 },
      ]},
    { title:'居酒屋邂逅', text:()=> '新桥某条小巷的暖帘里，一位刚下班的上班族举着啤酒邀你："要一起喝一杯吗？"',
      choices:[
        { label:'坐下来聊一晚上',     tip:'他唱了三首昭和老歌，第二天打工请你吃早餐。', reward: 60 },
        { label:'请他喝一杯就走',     tip:'你递给店员 ¥600 替他买单。', reward: -25 },
      ]},
    { title:'山手线书友', text:()=> '车厢里对座的人在读你也读过的村上春树。她抬头："这本书你看到第几页了？"',
      choices:[
        { label:'交换书签',     tip:'她送你一张她手画的书签。', reward: 10 },
        { label:'聊到下一站',   tip:'她把书借给你，留了便签让你下次还。', reward: 0 },
      ]},
  ],
  'jp-kyoto': [
    { title:'岚山竹林', text:()=> '清晨的竹林小路，一只奈良跑来的鹿挡在你面前不肯走。',
      choices:[
        { label:'掏一片饼干',   tip:'它嚼得很香，临走还鞠了一躬。', reward: -5 },
        { label:'绕过去',       tip:'它转头跟你走了 50 米。', reward: 0 },
      ]},
    { title:'茶道初体验', text:()=> '南禅寺旁的茶室里，茶道师傅看你拿茶筅的姿势不对，主动放下手中的事过来教你。',
      choices:[
        { label:'认真学一下午', tip:'你打的最后一碗茶，师傅说"还可以"。', reward: 30 },
        { label:'拍照留念',     tip:'师傅笑着摆姿势配合你。', reward: 5 },
      ]},
  ],
  'jp-osaka': [
    { title:'章鱼烧大叔', text:()=> '道顿堀路边摊大叔咧嘴一笑："今天最后一份，要不要？打折！"',
      choices:[
        { label:'买下来',       tip:'确实今天最入味。', reward: -8 },
        { label:'一起合影',     tip:'他比 V 字时手上还粘着木鱼花。', reward: 5 },
      ]},
  ],
  'cn-beijing': [
    { title:'胡同象棋', text:()=> '南锣鼓巷口，一位戴茶色墨镜的大爷正下到关键一步，他抬头："小同志，会下吗？要不要支个招？"',
      choices:[
        { label:'认真支招',     tip:'大爷输了棋却赢了你一杯茉莉花茶。', reward: 15 },
        { label:'硬着头皮上场', tip:'你输了一局，他乐得帮你买了俩煎饼。', reward: 0 },
      ]},
    { title:'天坛太极', text:()=> '清晨的天坛公园，一群大妈在打太极。其中一位招手："来跟我们练一段！"',
      choices:[
        { label:'比划一段',     tip:'大妈说你"很有架势"，硬塞给你两个茶叶蛋。', reward: 12 },
        { label:'拍照就溜',     tip:'你的相册多了一段晨光。', reward: 0 },
      ]},
  ],
  'cn-shanghai': [
    { title:'弄堂腌笃鲜', text:()=> '梧桐区某条弄堂，阿姨从灶上端出一锅腌笃鲜："小囡，要不要尝一口？"',
      choices:[
        { label:'坐下吃一碗',   tip:'吃完她又给你装了一袋小馄饨带走。', reward: 18 },
        { label:'谢谢，不了',   tip:'阿姨说"下次来啊"。', reward: 0 },
      ]},
    { title:'外滩老克勒', text:()=> '外滩夜风里，一位西装挺括的老先生轻声唱起《夜上海》。他冲你挥挥手："来，伴一段？"',
      choices:[
        { label:'搭一句',       tip:'路人鼓掌，他塞给你 ¥100 红包。', reward: 14 },
        { label:'静静听完',     tip:'他唱完冲你点头致意。', reward: 0 },
      ]},
  ],
  'cn-chengdu': [
    { title:'人民公园喝茶', text:()=> '鹤鸣茶社，一位掏耳朵师傅举着家伙凑过来："试一下嘛？只要 30 块。"',
      choices:[
        { label:'体验一下',     tip:'人生第一次掏耳朵，舒服到睡着。', reward: -10 },
        { label:'给来一杯盖碗茶',tip:'你坐了一下午，看人下棋。', reward: -5 },
      ]},
  ],
  'fr-paris': [
    { title:'塞纳河速写', text:()=> '左岸一位戴贝雷帽的画家招呼你："女士/先生，让我给你画一张？十分钟，€20。"',
      choices:[
        { label:'坐下来让他画', tip:'画里的你比镜子里好看。', reward: -22 },
        { label:'请他画你的旅伴',tip:'他笑了，免费送你一张速写。', reward: 0 },
        { label:'婉拒',         tip:'他冲你的背影挥手。', reward: 0 },
      ]},
    { title:'Flore 钢笔', text:()=> 'Café de Flore 邻桌一位老人在写信。他停下来："想试试我的派克 51 吗？陪伴我 30 年了。"',
      choices:[
        { label:'借来写一张明信片', tip:'他给你贴上邮票，让你寄给爱的人。', reward: 8 },
        { label:'婉拒',             tip:'他点头，继续写他的信。', reward: 0 },
      ]},
  ],
  'fr-bordeaux': [
    { title:'酒庄品酒', text:()=> '梅多克酒庄主邀请你品一支 2015 的圣埃斯泰夫："不收钱，只想问问亚洲人怎么看？"',
      choices:[
        { label:'仔细品评',   tip:'他送你一张签名酒标。', reward: 25 },
        { label:'豪迈干杯',   tip:'他大笑，又开了一瓶。', reward: 10 },
      ]},
  ],
  'gb-london': [
    { title:'Pub 庆胜', text:()=> '苏豪一家 Pub，老板突然敲钟："Arsenal won 3-0! Drinks on me!"',
      choices:[
        { label:'豪迈接过一杯',   tip:'你旁边的大叔抱着你转了三圈。', reward: 8 },
        { label:'点一杯加汽水',   tip:'老板说"good choice, mate"。', reward: 0 },
      ]},
    { title:'演讲角', text:()=> 'Hyde Park 西北角，一位辩论者突然指着你："你！你怎么看这个问题？"',
      choices:[
        { label:'一本正经反驳',   tip:'围观人群鼓掌，有人塞给你 £10。', reward: 12 },
        { label:'摇头微笑走开',   tip:'他继续找下一个目标。', reward: 0 },
      ]},
  ],
  'gb-edinburgh': [
    { title:'风笛艺人', text:()=> '皇家英里大道，一位穿苏格兰格子裙的风笛手正在演奏。他冲你点头："要点什么？"',
      choices:[
        { label:'点一首 Auld Lang Syne', tip:'他完整地吹了一遍，送你一枚硬币纪念章。', reward: -3 },
        { label:'丢一镑听完',           tip:'他对你竖起大拇指。', reward: -1 },
      ]},
  ],
  'us-newyork': [
    { title:'地铁艺人', text:()=> 'L 线车厢里，一支街头 Hip-Hop 团队即兴 freestyle。其中一位指你："Yo，你呢？来一句？"',
      choices:[
        { label:'冲上去 freestyle',   tip:'全车厢欢呼，他们塞给你 $30 现金。', reward: 30 },
        { label:'打节拍',             tip:'你的手掌拍红了。', reward: 5 },
        { label:'微笑摇头',           tip:'他们继续找下一个。', reward: 0 },
      ]},
    { title:'Times Square', text:()=> '一群裹着自由女神戏服的人在拉客合影。其中一个比 V："$5 一张，二送一！"',
      choices:[
        { label:'付钱合影',   tip:'你和"自由女神"一起举火炬。', reward: -5 },
        { label:'蹭背景拍',   tip:'看起来你也参加了游行。', reward: 0 },
      ]},
  ],
  'us-no': [
    { title:'波旁街爵士', text:()=> 'Frenchmen 街一家小酒馆传出萨克斯，门口的姑娘冲你比 V："今晚开放麦，要不要来一段？"',
      choices:[
        { label:'唱一首',   tip:'酒吧老板免费请你一杯飓风。', reward: 12 },
        { label:'坐着听完', tip:'你的整个夜晚都是蓝调。', reward: 0 },
      ]},
  ],
  'kr-seoul': [
    { title:'弘大 Noraebang', text:()=> '凌晨的弘大，一群刚下班的留学生拉住你："去 Noraebang 唱通宵吗？AA 制！"',
      choices:[
        { label:'欣然加入',   tip:'你唱了三首 IU，他们说"unni 你超棒"。', reward: -15 },
        { label:'明天再约',   tip:'你换了 KakaoTalk，约了下周。', reward: 0 },
      ]},
    { title:'明洞咖啡', text:()=> '咖啡店店员收钱时悄悄说："Eonni，今天给你升级到 venti。"',
      choices:[
        { label:'认真道谢',   tip:'她笑得眼睛弯弯。', reward: 0 },
        { label:'多点一份糕', tip:'她又偷偷加了一份榛子奶油。', reward: -4 },
      ]},
  ],
  'kr-jeju': [
    { title:'海女上岸', text:()=> '海岸边几位老海女刚收工，篮子里全是鲍鱼。一位招手："要不要尝一颗？"',
      choices:[
        { label:'付钱买一打', tip:'她们围着你聊起年轻时的潜水故事。', reward: -20 },
        { label:'尝一颗就走', tip:'她送你一袋小贝壳。', reward: 0 },
      ]},
  ],
  'au-sydney': [
    { title:'Bondi 冲浪', text:()=> 'Bondi 海滩，一位金发冲浪教练冲你挥手："Mate, 想抓你第一道浪吗？我免费教！"',
      choices:[
        { label:'拼了',         tip:'你被浪打了 6 次，第 7 次站起来了。', reward: 25 },
        { label:'晒太阳就好',   tip:'你的小麦色更深了一度。', reward: 0 },
      ]},
    { title:'Newtown 涂鸦', text:()=> 'King St 一面涂鸦墙前，作画的本地艺术家递你一罐喷漆："想加一笔吗？"',
      choices:[
        { label:'画一只小兔子', tip:'她笑了：明年就在墙上保留你这只兔子。', reward: 0 },
        { label:'拍照留念',     tip:'她把签名照赠你。', reward: 5 },
      ]},
  ],
  'nz-auckland': [
    { title:'渡轮 Haka', text:()=> '德文波特渡轮上，邻座的毛利长者向你点头："你想学一段 Haka 吗？我教你第一个动作。"',
      choices:[
        { label:'起立学一段',   tip:'整艘船的人都为你鼓掌。', reward: 20 },
        { label:'静听他讲',     tip:'你听到了一段两百年前的部落故事。', reward: 0 },
      ]},
  ],
  'nz-queenstown': [
    { title:'蹦极挑战', text:()=> '卡瓦劳大桥头，工作人员喊话："今天促销，七五折！要不要试？"',
      choices:[
        { label:'跳一次',   tip:'你尖叫了 8 秒，世界翻了三圈。', reward: -90 },
        { label:'给朋友录一段',tip:'你云体验也很激动。', reward: 0 },
      ]},
  ],
  'it-rome': [
    { title:'特莱维喷泉', text:()=> '你掏出硬币准备背对喷泉投币，旁边老太太轻轻拍你肩："右肩上方扔，更准。"',
      choices:[
        { label:'听她的扔',   tip:'三枚硬币都准，她笑着对你说"再来一次！"', reward: 0 },
        { label:'许个愿',     tip:'你的愿望被吹进了夜风。', reward: 0 },
      ]},
  ],
  'es-madrid': [
    { title:'Tapas 老板', text:()=> '一家老 Tapas 馆里，老板突然把整盘伊比利亚火腿端到你面前："今天请客，好不好？"',
      choices:[
        { label:'豪迈接受',   tip:'他给你倒了三杯雪利酒。', reward: 20 },
        { label:'点一杯啤酒回敬',tip:'他举杯：「¡Salud, amigo!」', reward: -6 },
      ]},
  ],
  'th-bangkok': [
    { title:'湄南河', text:()=> '黄昏的湄南河，一艘长尾船船夫向你比 V："Hello! Sunset boat? 100 baht!"',
      choices:[
        { label:'坐一程',   tip:'夕阳照在郑王庙上，你流泪了一秒。', reward: -3 },
        { label:'拍照就走', tip:'他冲你挥挥手。', reward: 0 },
      ]},
  ],
  'gr-athens': [
    { title:'卫城日落', text:()=> '卫城脚下，一位手风琴艺人正拉一首悲伤的希腊老歌。他冲你点头："你的故乡在哪？"',
      choices:[
        { label:'告诉他',   tip:'他用你的母语想了想，加了一个音节进曲子里。', reward: 10 },
        { label:'静静听完', tip:'你看见雅典娜从神庙走过。', reward: 0 },
      ]},
  ],
};
const GENERIC_ENCOUNTERS = [
  { title:'街头采访', text:(n)=>`在${n}的十字路口，电视台记者拦下你："请问你来自哪？"`,
    choices:[
      { label:'认真回答 + 介绍宠物', tip:'主持人很喜欢你的旅伴！', reward: 80 },
      { label:'害羞躲开',           tip:'你溜进咖啡店假装看菜单。', reward: 0 },
      { label:'反问：那你呢？',     tip:'记者笑了，赏你一张电视台明信片。', reward: 30 },
    ]},
  { title:'迷路的旅人', text:(n)=>`在${n}的小巷里，一位背着大包的旅人举着地图问你："这条街怎么走？"`,
    choices:[
      { label:'帮她带路 20 分钟', tip:'她送你一张她家乡的明信片。', reward: 30 },
      { label:'指路就走',         tip:'她笑着挥手再见。', reward: 10 },
    ]},
  { title:'当地朋友邀请', text:(n)=>`${n}的咖啡店里，邻桌的人问你要不要一起拼桌。`,
    choices:[
      { label:'欣然加入',     tip:'聊得很投机，对方请客。', reward: 25 },
      { label:'礼貌婉拒',     tip:'你保留了独处的下午。', reward: 0 },
    ]},
];
const GENERIC_ENCOUNTERS_EN = [
  { title:'Street Interview', text:(n)=>`At an intersection in ${n}, a TV reporter stops you: "Where are you from?"`,
    choices:[
      { label:'Answer warmly + show your pet', tip:'The host loves your travel companion!', reward: 80 },
      { label:'Slip away shyly',               tip:'You duck into a café and pretend to read the menu.', reward: 0 },
      { label:'"What about you?"',             tip:'The reporter laughs and hands you a postcard.', reward: 30 },
    ]},
  { title:'A Lost Traveler', text:(n)=>`In a back street of ${n}, a traveler with a big backpack lifts a map: "How do I find this street?"`,
    choices:[
      { label:'Walk her there (20 min)', tip:'She gives you a postcard from her hometown.', reward: 30 },
      { label:'Point and move on',       tip:'She waves goodbye.', reward: 10 },
    ]},
  { title:'A Local Invitation', text:(n)=>`At a café in ${n}, the next table asks if you\'d like to share their table.`,
    choices:[
      { label:'Happily join',  tip:'Great chat — they pick up the bill.', reward: 25 },
      { label:'Politely decline', tip:'You keep your quiet afternoon.', reward: 0 },
    ]},
];
function pickEncounter(city) {
  const cityId = typeof city === 'string' ? city : city.id;
  const lang = window.getLang ? window.getLang() : 'en';
  const cityName = typeof city === 'string' ? cityId : (lang === 'zh' ? (city.name_zh || city.name) : (city.name || city.name_zh));
  // Localized generic pool for non-Chinese players — pick player's lang, fallback English
  if (lang !== 'zh') {
    const pool = (window.ENCOUNTER_I18N && (window.ENCOUNTER_I18N[lang] || window.ENCOUNTER_I18N.en)) || GENERIC_ENCOUNTERS_EN;
    let h = 0;
    for (const c of cityId) h = (h * 31 + c.charCodeAt(0)) | 0;
    const g = pool[Math.abs(h) % pool.length];
    return { ...g, text: () => g.text(cityName) };
  }
  const local = CITY_ENCOUNTERS[cityId];
  if (local && local.length) {
    let h = 0;
    for (const c of cityId + Date.now().toString().slice(-3)) h = (h * 31 + c.charCodeAt(0)) | 0;
    return local[Math.abs(h) % local.length];
  }
  // generic Chinese fallback
  let h = 0;
  for (const c of cityId) h = (h * 31 + c.charCodeAt(0)) | 0;
  const g = GENERIC_ENCOUNTERS[Math.abs(h) % GENERIC_ENCOUNTERS.length];
  return { ...g, text: () => g.text(cityName) };
}
function EncounterModal({ city, onClose, onReward }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const enc = pickEncounter(city);
  const [picked, setPicked] = useState(null);
  const choose = (c) => {
    setPicked(c);
    if (c.reward) onReward?.(c.reward);
    setTimeout(onClose, 1800);
  };
  const cityLbl = lang === 'zh' ? (city.name_zh || city.name) : (city.name || city.name_zh);
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal encounter" onClick={e=>e.stopPropagation()}>
        <div className="encounter-tag">{T('encounter.title')} · {cityLbl}</div>
        <div className="encounter-title">{enc.title}</div>
        <div className="encounter-text">{enc.text(cityLbl)}</div>
        {!picked ? (
          <div className="encounter-choices">
            {enc.choices.map((c, i) => (
              <button key={i} className="encounter-choice" type="button" onClick={() => choose(c)}>
                {c.label}
              </button>
            ))}
          </div>
        ) : (
          <div className="encounter-result">
            <div className="enc-tip">{picked.tip}</div>
            {picked.reward !== 0 && (
              <div className={`enc-reward ${picked.reward > 0 ? 'gain' : 'loss'}`}>
                {picked.reward > 0 ? `+ $${picked.reward}` : `− $${Math.abs(picked.reward)}`}
              </div>
            )}
          </div>
        )}
      </div>
    </div>
  );
}

/* ===== safety scenarios · 真实情境 + 应对教学 · city-specific overrides ===== */
// 这些发生在单一标志性场所的剧情挂到具体子城市，而不是整国
const CITY_SAFETY = {
  'jp-tokyo': {
    title:'山手线痴汉', icon:'🚇', location:'通勤高峰 · 拥挤车厢',
    text:'早高峰挤满人的山手线车厢里，你感觉身后有手在试探。',
    choices:[
      { label:'假装没看到', outcome:'bad', tip:'⚠ 沉默只会助长。痴汉是反复犯罪，他知道你不会反抗。' },
      { label:'大声"やめてください！"+ 拍照', outcome:'good', tip:'✓ 正确。日本对痴汉零容忍，旁人会立刻帮忙。' },
      { label:'动手打他', outcome:'mid', tip:'△ 慎用。可能被反告"暴行罪"。先言语 + 取证。' },
    ],
    rule:'女性早晚高峰可坐「女性専用車両」；下站找驗票員。东京痴汉热线 03-5320-3110。',
  },
  'fr-paris': {
    title:'巴黎签名骗局', icon:'📝', location:'埃菲尔铁塔 / 圣心堂周边',
    text:'一群"聋哑学生"举着请愿书凑过来："Please sign for the deaf orphans"，你刚要签…',
    choices:[
      { label:'签个名给他们', outcome:'bad', tip:'⚠ 经典圈套。签完会被强讨"捐款"。' },
      { label:'坚定 "Non, merci" 不停步', outcome:'good', tip:'✓ 正确。看到立刻无视，加快脚步离开。' },
      { label:'掏钱包准备捐 5 欧', outcome:'bad', tip:'⚠ 给 5 欧他们会要 50。' },
    ],
    rule:'巴黎景点 scam：① 签名请愿 ② 强行系红绳 ③ 当面捡到金戒指。一律不接不签不捡。',
  },
  'it-rome': {
    title:'Termini 地铁扒手', icon:'🎒', location:'A 线 Termini 站',
    text:'地铁门关上前，三个穿连帽衫的少女突然挤过来，其中一个把围巾盖在你包上。',
    choices:[
      { label:'让她们过去', outcome:'bad', tip:'⚠ 包已经在打开。girl pickpocket 团伙手法。' },
      { label:'立刻反手抓住自己包大喊"VIA!"', outcome:'good', tip:'✓ 她们怕被认出立刻散开。包永远在身前。' },
      { label:'拿手机记录', outcome:'mid', tip:'△ 取证有用，但她们已经抢到了。' },
    ],
    rule:'背包永远在身前；钱护照分开装；现金 ≤ €100。',
  },
  'th-bangkok': {
    title:'今天大皇宫关闭', icon:'🛺', location:'考山路附近',
    text:'tuk-tuk 司机笑眯眯凑过来："Friend, Grand Palace closed today, special Buddhist day! I take you good temple, only 20 baht!"',
    choices:[
      { label:'上车去他推荐的寺庙', outcome:'bad', tip:'⚠ 拉去三家"宝石店"，每家拿 5% 提成强推假货。' },
      { label:'直接走开 + Google Maps 自己去', outcome:'good', tip:'✓ 大皇宫 365 天开放。"今天关闭"几乎都是骗局。' },
      { label:'追问"为什么关"', outcome:'bad', tip:'⚠ 给他演技发挥空间。' },
    ],
    rule:'曼谷 tuk-tuk 一律以"今天关 / 特别日"开头都是骗局。',
  },
  'gb-london': {
    title:'威斯敏斯特三杯局', icon:'🎲', location:'威斯敏斯特桥',
    text:'桥上一个人摆三个杯子和一个骰子，旁边围观的人不停猜对赢钱。',
    choices:[
      { label:'押 £20 试试', outcome:'bad', tip:'⚠ 全是托。摊主手快，不可能猜对。' },
      { label:'拍下他们脸路过', outcome:'good', tip:'✓ 凑过去本身就危险（同伙偷包）。' },
      { label:'给小孩 £5 让他玩', outcome:'bad', tip:'⚠ 等于送 £5。' },
    ],
    rule:'three-cup 永远是骗局，凑过去本身就危险。',
  },
  'eg-cairo': {
    title:'金字塔骆驼骗局', icon:'🐪', location:'吉萨高原',
    text:'你刚到金字塔，一个戴头巾的人热情拉你坐他骆驼："Free photo my friend! Just two minutes!"',
    choices:[
      { label:'坐上去拍张照', outcome:'bad', tip:'⚠ 上骆驼简单，下骆驼要"down 100$"。' },
      { label:'笑着 No thank you 走开', outcome:'good', tip:'✓ 要骑也走景区官方 booth（明码 €15-25 / 30 min）。' },
      { label:'拍他 / 骆驼后离开', outcome:'mid', tip:'△ 他会跟着追要"模特费"。' },
    ],
    rule:'埃及景点"免费 / Just look"开头一律陷阱。所有交易先问明确价 + 写下来。',
  },
};

// 国家级通用剧情 — text/location 接收当前城市名做参数化
const SAFETY_SCENARIOS = {
  USA: {
    title:'街头持枪抢劫', icon:'🚨',
    location:(city)=>`${city} · 偏僻街区夜晚`,
    text:(city)=>`深夜走回旅馆的捷径上，一个戴帽子的人慢慢靠近，从口袋里露出枪柄："Wallet, phone — now."`,
    choices:[
      { label:'反抗 / 大喊"救命"', outcome:'bad', tip:'⚠ 极度危险。美国持枪率高，反抗可能直接致命，不值得。' },
      { label:'慢慢举手，缓慢交出钱包手机', outcome:'good', tip:'✓ 正确。命比钱重要。事后立刻找安全处拨 911。' },
      { label:'假装心脏病发倒地', outcome:'mid', tip:'△ 不可预测，可能让对方更紧张反而开枪。' },
    ],
    rule:'保命第一。出门带"假诱饵 wallet"（少量现金 + 过期卡），真护照/卡留旅馆保险柜。',
  },
  JPN: {
    title:'电车上的痴汉', icon:'🚇',
    location:(city)=>`${city} · 通勤高峰拥挤车厢`,
    text:(city)=>`早高峰挤满人的${city}电车车厢里，你感觉身后有手在试探。`,
    choices:[
      { label:'假装没看到', outcome:'bad', tip:'⚠ 沉默只会助长。痴汉是反复犯罪，他知道你不会反抗。' },
      { label:'大声"やめてください！"+ 拍照', outcome:'good', tip:'✓ 正确。日本对痴汉零容忍，旁人会立刻帮忙。' },
      { label:'动手打他', outcome:'mid', tip:'△ 慎用。可能被反告"暴行罪"。先言语 + 取证。' },
    ],
    rule:'女性早晚高峰可坐「女性専用車両」；下站找驗票員。痴汉热线 03-5320-3110。',
  },
  FRA: {
    title:'景点"签名 / 红绳"骗局', icon:'📝',
    location:(city)=>`${city} · 主要景点周边`,
    text:(city)=>`一群"聋哑学生"举着请愿书凑过来："Please sign for the deaf orphans"，你刚要签…`,
    choices:[
      { label:'签个名给他们', outcome:'bad', tip:'⚠ 经典圈套。签完会被强讨"捐款"。' },
      { label:'坚定 "Non, merci" 不停步', outcome:'good', tip:'✓ 正确。看到立刻无视，加快脚步离开。' },
      { label:'掏钱包准备捐 5 欧', outcome:'bad', tip:'⚠ 给 5 欧他们会要 50。' },
    ],
    rule:'法国景点 scam：① 签名请愿 ② 强行系红绳 ③ 当面捡到金戒指。一律不接不签不捡。',
  },
  ITA: {
    title:'地铁拥挤扒手', icon:'🎒',
    location:(city)=>`${city} · 高峰地铁车厢`,
    text:(city)=>`${city} 地铁门关上前，几个穿连帽衫的少女突然挤过来，其中一个把围巾盖在你包上。`,
    choices:[
      { label:'让她们过去', outcome:'bad', tip:'⚠ 包已经在打开。girl pickpocket 团伙手法。' },
      { label:'立刻反手抓住自己包大喊"VIA!"', outcome:'good', tip:'✓ 她们怕被认出立刻散开。包永远在身前。' },
      { label:'拿手机记录', outcome:'mid', tip:'△ 取证有用，但她们已经抢到了。' },
    ],
    rule:'背包永远在身前；钱护照分开装；现金 ≤ €100。',
  },
  THA: {
    title:'tuk-tuk"今天关闭"骗局', icon:'🛺',
    location:(city)=>`${city} · 旅馆 / 景点门口`,
    text:(city)=>`tuk-tuk 司机笑眯眯凑过来："Friend, ${city} main attraction closed today, special Buddhist day! I take you good temple, only 20 baht!"`,
    choices:[
      { label:'上车去他推荐的地方', outcome:'bad', tip:'⚠ 他会拉去三家"宝石店"，每家拿 5% 提成强推假货。' },
      { label:'直接走开 + Google Maps 自己去', outcome:'good', tip:'✓ 泰国景点几乎全年开放。"今天关闭"99% 是骗局。' },
      { label:'追问"为什么关"', outcome:'bad', tip:'⚠ 给他演技发挥空间。' },
    ],
    rule:'泰国 tuk-tuk 一律以"今天关 / 特别日"开头都是骗局。',
  },
  IND: {
    title:'"假警察"街头勒索', icon:'👮',
    location:(city)=>`${city} · 机场周边 / 街头`,
    text:(city)=>`在 ${city} 路上被一个穿"警察制服"的人拦下："Sir, drug check, show me your wallet."`,
    choices:[
      { label:'马上交钱包', outcome:'bad', tip:'⚠ 真警察永远不在路上"现场检查钱包"。' },
      { label:'要求看警徽 + ID + 去最近警察局', outcome:'good', tip:'✓ 说"我们一起去 police station 处理"。假警察立刻消失。' },
      { label:'拿出 ₹1000 给他了事', outcome:'bad', tip:'⚠ 给一次他会拿走更多。' },
    ],
    rule:'真警察不会在路上突击查游客现金。提前下载 Uber，不要街招出租。',
  },
  BRA: {
    title:'街头持械抢劫', icon:'⚠️',
    location:(city)=>`${city} · 夜晚小巷`,
    text:(city)=>`晚 9 点单人走在 ${city} 的小巷，两个青少年一前一后接近，前面一个突然亮出小刀低声说"Celular, agora."`,
    choices:[
      { label:'狂奔', outcome:'bad', tip:'⚠ 可能被刀划。同伴守路口，跑反而进了埋伏。' },
      { label:'放下手机 + 钱包 + 退后', outcome:'good', tip:'✓ 90% 是为了换毒资，要钱不要命。给了就走。' },
      { label:'用葡语骂回去', outcome:'bad', tip:'⚠ 升级冲突。' },
    ],
    rule:'巴西夜晚不要单独走小巷；用便宜备用机；不戴金项链 / 名表；走大马路 + 多人区。',
  },
  ZAF: {
    title:'夜晚红灯 hijack', icon:'🚗',
    location:(city)=>`${city} · 夜晚红绿灯`,
    text:(city)=>`你开租来的车在 ${city} 红灯前等，一个人猛敲副驾窗，玻璃后是一把枪。`,
    choices:[
      { label:'闯红灯逃跑', outcome:'good', tip:'✓ 当地人都知道：夜里 hijack 多发，看周围安全就蹭过红灯。' },
      { label:'摇下窗看他要什么', outcome:'bad', tip:'⚠ 一摇窗他可能直接打开门把你拽出来开走车。' },
      { label:'按喇叭吓他', outcome:'bad', tip:'⚠ 让他知道你紧张，更可能开枪。' },
    ],
    rule:'南非夜里开车，红灯前永远保持 2 车身距离能看到前车后轮，随时可走。',
  },
  ARG: {
    title:'"芥末喷溅"分散注意', icon:'💩',
    location:(city)=>`${city} · 地铁口 / 旅馆附近`,
    text:(city)=>`在 ${city} 街上，背后突然有人提醒"先生，您背上有鸟屎"——其实是被人喷的芥末，"好心人"凑过来要帮你擦…`,
    choices:[
      { label:'让他们帮忙擦', outcome:'bad', tip:'⚠ 经典分散注意 + 偷包。' },
      { label:'立刻紧握自己的包跑去公共场所', outcome:'good', tip:'✓ 进咖啡店再处理，永远不让陌生人靠近自己的包。' },
      { label:'把包放下让自己干净点', outcome:'bad', tip:'⚠ 等于送上门。' },
    ],
    rule:'"鸟屎/芥末突然在身上 + 好心人帮擦" = 99% 团伙作案。',
  },
  CHN: {
    title:'机场拉客"黑车 / 黑导游"', icon:'🛬',
    location:(city)=>`${city} 火车站 / 机场到达`,
    text:(city)=>`刚拖着行李出站，一个穿"导游证"的中年男人凑过来："${city}一日游 200，包车 + 讲解！现在出发免排队！"`,
    choices:[
      { label:'跟他走', outcome:'bad', tip:'⚠ 路上绕路 + 强买玉器 + 午餐宰客。"包车 200" 实际花掉 2000+。' },
      { label:'坚决拒绝 + 用滴滴', outcome:'good', tip:'✓ 正规出租车 / 滴滴在出站口指定位置。' },
      { label:'还价到 100', outcome:'bad', tip:'⚠ 还价 = 上钩。' },
    ],
    rule:'机场火车站"价格特别便宜"的拉客一律是骗局。',
  },
  EGY: {
    title:'景点"免费骆驼/小费"', icon:'🐪',
    location:(city)=>`${city} · 主要景点入口`,
    text:(city)=>`你刚到 ${city} 景区，一个戴头巾的人热情拉你坐他骆驼/合影："Free photo my friend! Just two minutes!"`,
    choices:[
      { label:'拍照 / 坐上去', outcome:'bad', tip:'⚠ 拍完/上骆驼后立刻要"down 100$"。' },
      { label:'笑着 No thank you 走开', outcome:'good', tip:'✓ 走景区官方 booth（明码标价）。' },
      { label:'偷拍他 / 骆驼后离开', outcome:'mid', tip:'△ 他会跟着追要"模特费"。' },
    ],
    rule:'埃及景点"免费 / Just look"开头一律陷阱。所有交易先问明确价 + 写下来。',
  },
  GBR: {
    title:'街头赌局 / 三杯戏法', icon:'🎲',
    location:(city)=>`${city} · 旅游热点桥头 / 街角`,
    text:(city)=>`${city} 街头一个人摆三个杯子和一个骰子/牌，旁边围观的人不停"猜对"赢钱。`,
    choices:[
      { label:'押 £20 试试', outcome:'bad', tip:'⚠ 全是托。摊主手快，不可能猜对。' },
      { label:'拍下他们脸路过', outcome:'good', tip:'✓ 凑过去本身就危险（同伙偷包）。' },
      { label:'给小孩 £5 让他玩', outcome:'bad', tip:'⚠ 等于送 £5。' },
    ],
    rule:'three-cup / find the lady 永远是骗局，凑过去本身就危险。',
  },
  HKG: {
    title:'珠宝店"老字号"强买', icon:'💎',
    location:(city)=>`${city} · 旅游商业街`,
    text:(city)=>`你在 ${city} 被一个西装大叔拉去看"老字号"珠宝店："Sir，免费看，不买没关系。"`,
    choices:[
      { label:'买一件最便宜的算了', outcome:'bad', tip:'⚠ 即使最便宜的也至少加 5-10 倍价。' },
      { label:'坚定说"我看一下" + 5 分钟出店', outcome:'good', tip:'✓ 大声说"我要去警察局"对方立刻放手。' },
      { label:'装睡装病装老婆催', outcome:'good', tip:'✓ 也行。' },
    ],
    rule:'被陌生热心人带去店一律不进。要买玉去机场免税或正规商场。',
  },
};

function SafetyScenario({ countryId, cityId, cityName, onClose }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  // For non-Chinese mode, use a generic English safety prompt rather than the
  // Chinese-only city/country specifics. (Localizing the rich scenarios is a
  // future iteration; for now don't show Chinese to non-zh players.)
  if (lang !== 'zh') {
    const pool = window.SAFETY_I18N || {};
    const tmpl = pool[lang] || pool.en;
    const generic = tmpl ? {
      icon: tmpl.icon, title: tmpl.title, loc: cityName || 'Local',
      text: typeof tmpl.text === 'function' ? tmpl.text(cityName || 'this city') : tmpl.text,
      choices: tmpl.choices, rule: tmpl.rule, done: tmpl.done,
    } : {
      icon:'🚕', title:'Local safety', loc: cityName || 'Local',
      text:`Stepping out of the station, a stranger offers a tour at a suspiciously low price.`,
      choices:[
        { label:'Politely decline', tip:'You take official transit.', outcome:'good' },
        { label:'Just go with them', tip:'You got overcharged.', outcome:'bad' },
      ],
      rule:'Use official transport; agree on price first.',
      done:'OK',
    };
    const [picked, setPicked] = useState(null);
    return (
      <div className="modal-overlay" onClick={onClose}>
        <div className="modal safety-modal" onClick={e => e.stopPropagation()}>
          <div className="sf-tag">⚠ {T('section.archive') /* generic safety tag */}</div>
          <div className="sf-icon">{generic.icon}</div>
          <div className="sf-title">{generic.title}</div>
          <div className="sf-loc">📍 {generic.loc}</div>
          <div className="sf-text">{generic.text}</div>
          {!picked ? (
            <div className="sf-choices">
              {generic.choices.map((c, i) => (
                <button key={i} className="sf-choice" type="button" onClick={() => setPicked(c)}>{c.label}</button>
              ))}
            </div>
          ) : (
            <div className={`sf-result outcome-${picked.outcome}`}>
              <div className="sf-tip">{picked.tip}</div>
              <div className="sf-rule">
                <div className="sfr-h">📖</div>
                <div className="sfr-body">{generic.rule}</div>
              </div>
              <button className="btn primary sf-close" onClick={onClose}>{generic.done || T('btn.continue')}</button>
            </div>
          )}
        </div>
      </div>
    );
  }
  // city-specific override > country-level fallback (Chinese mode keeps full detail)
  const sc = (cityId && CITY_SAFETY[cityId]) || SAFETY_SCENARIOS[countryId];
  const [picked, setPicked] = useState(null);
  if (!sc) return null;
  // resolve location/text — they may be functions (city-aware) or static strings
  const cityLabel = cityName || '当地';
  const loc = typeof sc.location === 'function' ? sc.location(cityLabel) : sc.location;
  const txt = typeof sc.text === 'function' ? sc.text(cityLabel) : sc.text;
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal safety-modal" onClick={e => e.stopPropagation()}>
        <div className="sf-tag">⚠ 当地安全提示 · LOCAL SAFETY</div>
        <div className="sf-icon">{sc.icon}</div>
        <div className="sf-title">{sc.title}</div>
        <div className="sf-loc">📍 {loc}</div>
        <div className="sf-text">{txt}</div>
        {!picked ? (
          <div className="sf-choices">
            {sc.choices.map((c, i) => (
              <button key={i} className="sf-choice" type="button" onClick={() => setPicked(c)}>
                {c.label}
              </button>
            ))}
          </div>
        ) : (
          <div className={`sf-result outcome-${picked.outcome}`}>
            <div className="sf-tip">{picked.tip}</div>
            <div className="sf-rule">
              <div className="sfr-h">📖 实用建议</div>
              <div className="sfr-body">{sc.rule}</div>
            </div>
            <button className="btn primary sf-close" onClick={onClose}>知道了，记下来</button>
          </div>
        )}
      </div>
    </div>
  );
}

/* ===== auth · Google / Apple OAuth 一秒登录 ===== */
function AuthModal({ onClose }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const A = lang === 'zh' ? {
    tag:'一秒登录 · 无需密码',
    desc:'CONY 用你的 Google 账号一秒登录。',
    descSub:'登录后才能加好友、看到对方留言、被邀请同行。',
    redirecting:'跳转中…', google:'用 Google 继续',
    foot:'登录即表示同意把公开信息存到 Supabase。不会读取邮件 / 联系人 / 文件。',
    failConfig:'Supabase 未配置', failLogin:'登录失败：',
    emailToggle:'用邮箱登录', emailHide:'隐藏邮箱登录',
    emailPh:'邮箱', passwordPh:'密码', emailSignIn:'用邮箱登录',
    emailHint:'仅用于审核 / 演示账户。普通用户请用 Google 登录。',
  } : lang === 'ko' ? {
    tag:'원탭 로그인 · 비밀번호 불필요',
    desc:'CONY는 Google 계정으로 즉시 로그인합니다.',
    descSub:'로그인하면 친구를 추가하고, 친구의 일기를 보고, 함께 여행할 수 있습니다.',
    redirecting:'이동 중…', google:'Google로 계속',
    foot:'로그인 시 닉네임과 공개 정보가 Supabase에 저장됩니다. 이메일/연락처/파일은 절대 읽지 않습니다.',
    failConfig:'Supabase 미설정', failLogin:'로그인 실패: ',
    emailToggle:'이메일로 로그인', emailHide:'이메일 로그인 숨기기',
    emailPh:'이메일', passwordPh:'비밀번호', emailSignIn:'이메일로 로그인',
    emailHint:'심사/데모 계정 전용. 일반 사용자는 Google 로그인을 사용하세요.',
  } : {
    tag:'One-tap sign-in · no password',
    desc:'CONY signs you in instantly with Google.',
    descSub:'Sign in to add friends, see their messages, and be invited along.',
    redirecting:'Redirecting…', google:'Continue with Google',
    foot:'By signing in, your nickname and public info are stored in Supabase. We never read your email / contacts / files.',
    failConfig:'Supabase not configured', failLogin:'Sign-in failed: ',
    emailToggle:'Sign in with email', emailHide:'Hide email sign-in',
    emailPh:'Email', passwordPh:'Password', emailSignIn:'Sign in with email',
    emailHint:'For review / demo accounts. Regular users should sign in with Google.',
  };
  const [busy, setBusy] = useState(null);
  const [emailMode, setEmailMode] = useState(false);
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const supa = window.SUPA;
  const signIn = async (provider) => {
    if (!supa) { (window.conyAlert || window.alert)(A.failConfig); return; }
    setBusy(provider);
    const { error } = await supa.auth.signInWithOAuth({
      provider,
      options: { redirectTo: window.location.origin },
    });
    if (error) { setBusy(null); (window.conyAlert || window.alert)(A.failLogin + error.message); }
  };
  // Apple Sign-In — native iOS only. Bridges to ASAuthorizationAppleIDProvider
  // and hands the resulting id-token to Supabase signInWithIdToken.
  const hasAppleBridge = !!(window.cony?.appleSignIn?.signIn);
  const signInWithApple = async () => {
    if (!supa) { (window.conyAlert || window.alert)(A.failConfig); return; }
    setBusy('apple');
    try {
      const result = await window.cony.appleSignIn.signIn();
      if (!result?.ok) {
        if (result?.status === 'cancelled') return;
        (window.conyAlert || window.alert)(A.failLogin + (result?.error || 'unknown'));
        return;
      }
      const { error } = await supa.auth.signInWithIdToken({
        provider: 'apple',
        token: result.token,
        nonce: result.nonce,
      });
      if (error) {
        (window.conyAlert || window.alert)(A.failLogin + error.message);
      } else {
        // First-time Apple Sign-In gives us fullName / email; persist to profile.
        if (result.fullName) {
          try {
            const { data: { user } } = await supa.auth.getUser();
            if (user) {
              await supa.from('profiles')
                .upsert({ id: user.id, display_name: result.fullName }, { onConflict: 'id' });
            }
          } catch (_) { /* best-effort */ }
        }
        onClose?.();
      }
    } catch (e) {
      (window.conyAlert || window.alert)(A.failLogin + (e?.message || e));
    } finally {
      setBusy(null);
    }
  };

  // Email/password sign-in path — exists primarily so the App Store reviewer
  // (and any tester without a Google account) can still log in. Production
  // users still see Google as the primary CTA.
  const signInWithEmail = async () => {
    if (!supa) { (window.conyAlert || window.alert)(A.failConfig); return; }
    if (!email.trim() || !password) return;
    setBusy('email');
    const { error } = await supa.auth.signInWithPassword({ email: email.trim(), password });
    setBusy(null);
    if (error) {
      (window.conyAlert || window.alert)(A.failLogin + error.message);
    } else {
      onClose?.();
    }
  };
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal auth-modal" onClick={e => e.stopPropagation()}>
        <div className="modal-head">
          <div className="modal-title">{T('top.signin')}</div>
          <button className="modal-close" onClick={onClose}>×</button>
        </div>
        <div className="modal-body">
          <div className="auth-tag">{A.tag}</div>
          <p className="auth-desc">
            {A.desc}<br/>
            <span className="dim">{A.descSub}</span>
          </p>
          {hasAppleBridge && (
            <button className="oauth-btn apple" type="button" disabled={!!busy} onClick={signInWithApple}>
              <span className="oauth-icon" aria-hidden="true">
                <svg viewBox="0 0 24 24" width="20" height="20">
                  <path fill="currentColor" d="M16.365 1.43c0 1.14-.39 2.23-1.16 3.05-.77.83-2 1.49-3.21 1.4-.13-1.12.39-2.27 1.13-3.06.81-.86 2.19-1.51 3.24-1.39zm4.51 17.05c-.61 1.4-.92 2.02-1.71 3.26-1.1 1.73-2.66 3.89-4.59 3.9-1.72.02-2.16-1.12-4.49-1.1-2.33.02-2.82 1.13-4.54 1.11-1.94-.01-3.41-1.97-4.51-3.7-3.07-4.86-3.39-10.55-1.5-13.58 1.34-2.15 3.46-3.41 5.45-3.41 2.03 0 3.31 1.13 5 1.13 1.63 0 2.62-1.13 4.97-1.13 1.78 0 3.66.98 5 2.66-4.39 2.42-3.69 8.71.92 10.86z"/>
                </svg>
              </span>
              <span>{busy === 'apple' ? A.redirecting : (lang === 'zh' ? '用 Apple 继续' : lang === 'ko' ? 'Apple로 계속' : lang === 'ja' ? 'Apple で続ける' : 'Continue with Apple')}</span>
            </button>
          )}
          <button className="oauth-btn google" disabled={!!busy} onClick={() => signIn('google')}>
            <span className="oauth-icon" aria-hidden="true">
              <svg viewBox="0 0 24 24" width="20" height="20">
                <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
                <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0 0 12 23z"/>
                <path fill="#FBBC05" d="M5.84 14.09a6.6 6.6 0 0 1 0-4.18V7.07H2.18a10.97 10.97 0 0 0 0 9.86l3.66-2.84z"/>
                <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A10.99 10.99 0 0 0 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"/>
              </svg>
            </span>
            <span>{busy === 'google' ? A.redirecting : A.google}</span>
          </button>

          <button className="auth-email-toggle" type="button" onClick={() => setEmailMode(v => !v)}>
            {emailMode ? A.emailHide : A.emailToggle}
          </button>
          {emailMode && (
            <div className="auth-email-form">
              <input
                type="email"
                className="auth-input"
                placeholder={A.emailPh}
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                autoCapitalize="none"
                autoCorrect="off"
                spellCheck={false}
              />
              <input
                type="password"
                className="auth-input"
                placeholder={A.passwordPh}
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                onKeyDown={(e) => { if (e.key === 'Enter') signInWithEmail(); }}
              />
              <button
                className="btn primary"
                type="button"
                onClick={signInWithEmail}
                disabled={!!busy || !email.trim() || !password}
              >
                {busy === 'email' ? A.redirecting : A.emailSignIn}
              </button>
              <p className="auth-email-hint">{A.emailHint}</p>
            </div>
          )}

          <p className="auth-foot">{A.foot}</p>
        </div>
      </div>
    </div>
  );
}

/* ===== online friends bar · 在线好友 + 当前所在城市 ===== */
function OnlineFriendsBar() {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const auth = window.__conyAuth || {};
  const friends = auth.friends || [];
  if (friends.length === 0) return null;
  const tagLabel = lang === 'zh' ? '好友在路上' : 'Friends on the road';
  return (
    <div className="online-friends-bar">
      <span className="ofb-tag">{tagLabel}</span>
      <div className="ofb-list">
        {friends.map(f => (
          <div key={f.id} className="ofb-friend" title={f.current_city ? `${f.display_name} · ${f.current_city}` : f.display_name}>
            <span className="ofb-avatar">{(f.display_name || '?').charAt(0)}</span>
            <span className="ofb-name">{f.display_name}</span>
            {f.current_city && <span className="ofb-city">· {f.current_city}</span>}
          </div>
        ))}
      </div>
    </div>
  );
}

/* ===== friends · 真好友取代 NPC 同行 ===== */
function FriendsModal({ user, profile, onClose }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const F = lang === 'zh' ? {
    list:'我的好友', in:'收到', out:'已发送', add:'添加',
    nofriends:'还没有好友。', addhint:'去 "添加" tab 用昵称搜索。',
    noinc:'没有待处理的好友请求。', wantsfriend:'想加你为好友',
    nofound:'没找到', addbtn:'+ 加好友', search:'搜用户名',
    invSent:'好友请求已发送', invFail:'发送失败：',
    accFail:'接受失败：', rejFail:'拒绝失败：',
    accept:'接受', reject:'拒绝',
  } : {
    list:'My friends', in:'Incoming', out:'Sent', add:'Add',
    nofriends:'No friends yet.', addhint:'Use the "Add" tab to search by username.',
    noinc:'No pending requests.', wantsfriend:'wants to be your friend',
    nofound:'No match for', addbtn:'+ Add', search:'Search username',
    invSent:'Friend request sent', invFail:'Send failed: ',
    accFail:'Accept failed: ', rejFail:'Reject failed: ',
    accept:'Accept', reject:'Reject',
  };
  const supa = window.SUPA;
  const [tab, setTab] = useState('list');
  const [friends, setFriends] = useState([]);          // accepted
  const [incoming, setIncoming] = useState([]);        // pending where I'm user_b
  const [outgoing, setOutgoing] = useState([]);        // pending where I'm user_a
  const [search, setSearch] = useState('');
  const [results, setResults] = useState([]);
  const [busy, setBusy] = useState(false);
  const [travelFriend, setTravelFriend] = useState(null); // friend to view travel record

  const load = async () => {
    if (!supa || !user) return;
    const { data } = await supa.from('friendships').select('*').or(`user_a.eq.${user.id},user_b.eq.${user.id}`);
    if (!data) return;
    const accepted = data.filter(f => f.status === 'accepted');
    const inc = data.filter(f => f.status === 'pending' && f.user_b === user.id);
    const out = data.filter(f => f.status === 'pending' && f.user_a === user.id);
    // resolve other-user profiles
    const otherIds = Array.from(new Set([
      ...accepted.flatMap(f => [f.user_a, f.user_b]).filter(id => id !== user.id),
      ...inc.map(f => f.user_a),
      ...out.map(f => f.user_b),
    ]));
    let profilesMap = {};
    if (otherIds.length) {
      const { data: profs } = await supa.from('profiles').select('*').in('id', otherIds);
      (profs || []).forEach(p => profilesMap[p.id] = p);
    }
    const dec = (f, otherId) => ({ ...f, other: profilesMap[otherId] || { id: otherId, display_name: '?' } });
    setFriends(accepted.map(f => dec(f, f.user_a === user.id ? f.user_b : f.user_a)));
    setIncoming(inc.map(f => dec(f, f.user_a)));
    setOutgoing(out.map(f => dec(f, f.user_b)));
  };
  useEffect(() => { load(); }, [user?.id]); // eslint-disable-line

  // realtime
  useEffect(() => {
    if (!supa || !user) return;
    const ch = supa.channel('friends-' + user.id)
      .on('postgres_changes',
          { event: '*', schema: 'public', table: 'friendships' },
          () => load())
      .subscribe();
    return () => supa.removeChannel(ch);
  }, [user?.id]); // eslint-disable-line

  const doSearch = async () => {
    if (!search.trim() || !supa) return;
    setBusy(true);
    const { data } = await supa
      .from('profiles')
      .select('*')
      .ilike('display_name', `%${search.trim()}%`)
      .neq('id', user.id)
      .limit(20);
    setBusy(false);
    setResults(data || []);
  };
  const sendInvite = async (otherId) => {
    if (!supa) return;
    // sort ids so user_a < user_b to avoid duplicates A->B & B->A
    const a = user.id, b = otherId;
    const { error } = await supa.from('friendships').insert({
      user_a: a, user_b: b, status: 'pending',
    });
    if (error && !error.message.includes('duplicate')) {
      (window.conyAlert || window.alert)(F.invFail + error.message);
      return;
    }
    (window.conyAlert || window.alert)(F.invSent);
    load();
  };
  const accept = async (id) => {
    const { error } = await supa.from('friendships').update({ status: 'accepted' }).eq('id', id);
    if (error) { (window.conyAlert || window.alert)(F.accFail + error.message); return; }
    load();
  };
  const reject = async (id) => {
    const { error } = await supa.from('friendships').delete().eq('id', id);
    if (error) { (window.conyAlert || window.alert)(F.rejFail + error.message); return; }
    load();
  };

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal friends-modal" onClick={e => e.stopPropagation()}>
        <div className="modal-head">
          <div className="modal-title">{T('top.friends')}</div>
          <button className="modal-close" onClick={onClose}>×</button>
        </div>
        <div className="modal-summary">
          <div className="ms-tabs">
            <button className={`ms-tab ${tab === 'list' ? 'active' : ''}`} onClick={() => setTab('list')}>{F.list} ({friends.length})</button>
            <button className={`ms-tab ${tab === 'in' ? 'active' : ''}`} onClick={() => setTab('in')}>{F.in} ({incoming.length})</button>
            <button className={`ms-tab ${tab === 'out' ? 'active' : ''}`} onClick={() => setTab('out')}>{F.out} ({outgoing.length})</button>
            <button className={`ms-tab ${tab === 'add' ? 'active' : ''}`} onClick={() => setTab('add')}>{F.add}</button>
          </div>
        </div>
        <div className="modal-body">
          {tab === 'list' && (
            friends.length === 0
              ? <div className="empty">{F.nofriends}<br/>{F.addhint}</div>
              : <div className="friends-list">
                  {friends.map(f => (
                    <div key={f.id} className="friend-row">
                      <div className="fr-avatar">{(f.other.display_name || '?').charAt(0)}</div>
                      <div className="fr-info">
                        <div className="fr-name">{f.other.display_name}</div>
                        {f.other.current_city && <div className="fr-city">📍 {f.other.current_city}</div>}
                      </div>
                      <button className="btn ghost fr-act fr-travel-btn" onClick={() => setTravelFriend(f.other)}>
                        🧳 {T('chat.travel') || 'Travel'}
                      </button>
                    </div>
                  ))}
                </div>
          )}
          {tab === 'in' && (
            incoming.length === 0
              ? <div className="empty">{F.noinc}</div>
              : <div className="friends-list">
                  {incoming.map(f => (
                    <div key={f.id} className="friend-row">
                      <div className="fr-avatar">{(f.other.display_name || '?').charAt(0)}</div>
                      <div className="fr-info">
                        <div className="fr-name">{f.other.display_name}</div>
                        <div className="fr-city">{F.wantsfriend}</div>
                      </div>
                      <button className="btn primary fr-act" onClick={() => accept(f.id)}>{F.accept}</button>
                      <button className="btn ghost fr-act" onClick={() => reject(f.id)}>{F.reject}</button>
                    </div>
                  ))}
                </div>
          )}
          {tab === 'out' && (
            outgoing.length === 0
              ? <div className="empty">{F.noinc}</div>
              : <div className="friends-list">
                  {outgoing.map(f => (
                    <div key={f.id} className="friend-row">
                      <div className="fr-avatar">{(f.other.display_name || '?').charAt(0)}</div>
                      <div className="fr-info">
                        <div className="fr-name">{f.other.display_name}</div>
                        <div className="fr-city">{T('common.loading')}</div>
                      </div>
                      <button className="btn ghost fr-act" onClick={() => reject(f.id)}>{T('btn.cancel')}</button>
                    </div>
                  ))}
                </div>
          )}
          {tab === 'add' && (
            <div className="friends-search">
              <p className="auth-desc">{F.search}:</p>
              <div className="fs-row">
                <input
                  type="text"
                  value={search}
                  onChange={e=>setSearch(e.target.value)}
                  onKeyDown={e=>e.key==='Enter' && doSearch()}
                  placeholder={F.search}
                  className="auth-input"
                  style={{margin:0, flex:1}}
                />
                <button className="btn primary" onClick={doSearch} disabled={busy}>{T('btn.search')}</button>
              </div>
              <div className="friends-list" style={{marginTop:12}}>
                {results.map(p => (
                  <div key={p.id} className="friend-row">
                    <div className="fr-avatar">{(p.display_name || '?').charAt(0)}</div>
                    <div className="fr-info">
                      <div className="fr-name">{p.display_name}</div>
                      {p.current_city && <div className="fr-city">📍 {p.current_city}</div>}
                    </div>
                    <button className="btn primary fr-act" onClick={() => sendInvite(p.id)}>{F.addbtn}</button>
                  </div>
                ))}
                {results.length === 0 && search && !busy && (
                  <div className="empty">{F.nofound} "{search}"</div>
                )}
              </div>
            </div>
          )}
        </div>
      </div>
      {travelFriend && (
        <FriendTravelModal user={user} friend={travelFriend} onClose={() => setTravelFriend(null)}/>
      )}
    </div>
  );
}

/* ===== view a friend's travel record (visited countries + stats) ===== */
function FriendTravelModal({ user, friend, onClose }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const supa = window.SUPA;
  const [record, setRecord] = useState(null);
  const [diary, setDiary] = useState([]);
  const [loading, setLoading] = useState(true);
  const [recordErr, setRecordErr] = useState(null);
  const [diaryErr, setDiaryErr] = useState(null);

  useEffect(() => {
    if (!supa || !friend) return;
    let alive = true;
    Promise.all([
      supa.from('travel_records').select('*').eq('user_id', friend.id).maybeSingle(),
      supa.from('diary_entries').select('*')
        .eq('user_id', friend.id)
        .eq('visibility', 'public')
        .order('created_at', { ascending: false })
        .limit(50),
    ]).then(([recRes, diaryRes]) => {
      if (!alive) return;
      setLoading(false);
      if (recRes.error) { console.warn('travel rec err', recRes.error); setRecordErr(recRes.error); }
      else setRecord(recRes.data);
      if (diaryRes.error) { console.warn('friend diary err', diaryRes.error); setDiaryErr(diaryRes.error); }
      else setDiary(diaryRes.data || []);
    });
    return () => { alive = false; };
  }, [friend?.id]);

  const ago = (iso) => {
    if (!iso) return '';
    const m = (Date.now() - new Date(iso).getTime()) / 60000;
    if (m < 1) return '·';
    if (lang === 'zh') {
      if (m < 60) return `${Math.floor(m)} 分钟前`;
      if (m < 1440) return `${Math.floor(m / 60)} 小时前`;
      return `${Math.floor(m / 1440)} 天前`;
    }
    if (m < 60) return `${Math.floor(m)}m ago`;
    if (m < 1440) return `${Math.floor(m / 60)}h ago`;
    return `${Math.floor(m / 1440)}d ago`;
  };

  const name = friend.display_name || '?';
  const hasAny = record || diary.length > 0;
  return (
    <div className="modal-overlay friend-travel-overlay" onClick={onClose}>
      <div className="modal friend-travel-modal" onClick={e => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose}>×</button>
        <div className="ft-head">
          <div className="ft-avatar">{(name).charAt(0)}</div>
          <div className="ft-meta">
            <div className="ft-name">{name}</div>
            {friend.current_city && <div className="ft-city">📍 {friend.current_city}</div>}
          </div>
        </div>
        {loading ? (
          <div className="ft-loading">{T('chat.loading') || 'Loading…'}</div>
        ) : recordErr && diaryErr ? (
          <div className="ft-empty">
            {lang === 'zh' ? '加载失败：' : 'Load failed: '}{recordErr.message || String(recordErr)}
            <div style={{fontSize:'11px', marginTop:'8px', opacity:0.7}}>
              {lang === 'zh'
                ? '可能是 travel_records 表未创建。在 Supabase SQL Editor 运行 sql/travel_records.sql。'
                : 'The travel_records table may not exist. Run sql/travel_records.sql in Supabase SQL Editor.'}
            </div>
          </div>
        ) : !hasAny ? (
          <div className="ft-empty">
            {lang === 'zh'
              ? '对方还没有同步任何旅行记录或公开日记。'
              : 'This friend has not synced any travel record or public diary yet.'}
            <div style={{fontSize:'11px', marginTop:'8px', opacity:0.7}}>
              {lang === 'zh'
                ? '提示：好友登录后，在游戏里走动几次、5 秒后才会自动同步。'
                : 'Tip: travel data syncs 5s after the friend signs in and moves around.'}
            </div>
          </div>
        ) : (
          <>
            {record && (
              <>
                <div className="ft-passport">
                  <span className="ft-flag">{window.flagFor ? window.flagFor(record.passport_country) : '🌐'}</span>
                  <span className="ft-label">{T('visa.passport') || 'Passport'}</span>
                  <span className="ft-country">{window.countryLabel ? window.countryLabel(record.passport_country) : record.passport_country}</span>
                  {record.passport_tier && <span className="ft-tier" data-tier={record.passport_tier}>T{record.passport_tier}</span>}
                </div>
                <div className="ft-stats">
                  <div className="ft-stat">
                    <div className="ft-stat-num">{(record.visited_countries || []).length}</div>
                    <div className="ft-stat-lbl">{T('go.countries') || 'countries'}</div>
                  </div>
                  <div className="ft-stat">
                    <div className="ft-stat-num">{(record.visited_cities || []).length}</div>
                    <div className="ft-stat-lbl">{T('go.cities') || 'cities'}</div>
                  </div>
                  <div className="ft-stat">
                    <div className="ft-stat-num">{record.ticket_count || 0}</div>
                    <div className="ft-stat-lbl">{T('go.flights') || 'flights'}</div>
                  </div>
                  {record.total_km && (
                    <div className="ft-stat">
                      <div className="ft-stat-num">{record.total_km.toLocaleString()}</div>
                      <div className="ft-stat-lbl">{T('go.km') || 'km'}</div>
                    </div>
                  )}
                </div>
                <div className="ft-stamps-title">{T('go.passport') || 'Stamps'}</div>
                <div className="ft-stamps">
                  {(record.visited_countries || []).map(c => (
                    <div key={c} className="ft-stamp">
                      <span className="ft-stamp-flag">{window.flagFor ? window.flagFor(c) : '🌐'}</span>
                      <span className="ft-stamp-name">{window.countryLabel ? window.countryLabel(c) : c}</span>
                    </div>
                  ))}
                </div>
              </>
            )}
            <div className="ft-diary-title">
              {(lang === 'zh' ? '旅行日记' : 'Travel diary')} · {diary.length}
            </div>
            {diary.length === 0 ? (
              <div className="ft-diary-empty">
                {lang === 'zh' ? '还没有公开的日记。' : 'No public diary entries yet.'}
              </div>
            ) : (
              <div className="ft-diary-list">
                {diary.map(d => (
                  <div key={d.id} className="ft-diary-entry">
                    <div className="ft-diary-head">
                      <span className="ft-diary-mood">{d.mood || '🙂'}</span>
                      <span className="ft-diary-ttl">{d.title}</span>
                      <span className="ft-diary-ago">{ago(d.created_at)}</span>
                    </div>
                    {(d.city_name || d.country_id) && (
                      <div className="ft-diary-loc">
                        📍 {d.city_name}{d.country_id ? ` · ${window.countryLabel ? window.countryLabel(d.country_id) : d.country_id}` : ''}
                      </div>
                    )}
                    <div className="ft-diary-body">{d.body}</div>
                  </div>
                ))}
              </div>
            )}
          </>
        )}
      </div>
    </div>
  );
}

function ProfileSetup({ user, onDone, onClose }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const P = lang === 'zh' ? {
    title:'完善档案', tag:'登录成功',
    desc:'给自己起个公开昵称，朋友可以用它找到你：',
    placeholder:'昵称（如 阿明 / Yuki）', save:'保存档案', saving:'保存中…',
    needName:'请填一个昵称', failSave:'保存失败：',
  } : {
    title:'Set up your profile', tag:'Signed in',
    desc:'Pick a public nickname so friends can find you:',
    placeholder:'Nickname', save:'Save', saving:'Saving…',
    needName:'Please enter a nickname', failSave:'Save failed: ',
  };
  const supa = window.SUPA;
  const initial =
    user.user_metadata?.full_name ||
    user.user_metadata?.name ||
    localStorage.getItem('cony.nickname') ||
    user.email?.split('@')[0] ||
    '';
  const [name, setName] = useState(initial);
  const [busy, setBusy] = useState(false);
  const save = async () => {
    if (!name.trim()) { (window.conyAlert || window.alert)(P.needName); return; }
    if (!supa) return;
    setBusy(true);
    const { error } = await supa.from('profiles').upsert({
      id: user.id,
      display_name: name.trim(),
    });
    setBusy(false);
    if (error) { (window.conyAlert || window.alert)(P.failSave + error.message); return; }
    localStorage.setItem('cony.nickname', name.trim());
    onDone?.();
  };
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal auth-modal" onClick={e => e.stopPropagation()}>
        <div className="modal-head">
          <div className="modal-title">{P.title}</div>
        </div>
        <div className="modal-body">
          <div className="auth-tag">{P.tag} · {user.email}</div>
          <p className="auth-desc">{P.desc}</p>
          <input
            type="text"
            value={name}
            onChange={e=>setName(e.target.value)}
            placeholder={P.placeholder}
            maxLength={20}
            className="auth-input"
          />
          <button className="btn primary auth-send" onClick={save} disabled={busy}>
            {busy ? P.saving : P.save}
          </button>
        </div>
      </div>
    </div>
  );
}

/* ===== i18n · LanguagePicker (i18n table is in i18n.js) ===== */
/* Language picker — Korean-aesthetic single list with search */
function LanguagePicker({ onClose }) {
  const t = (window.useT ? window.useT() : (k) => k);
  const cur = window.getLang ? window.getLang() : 'en';
  const [, force] = useState(0);
  const [query, setQuery] = useState('');
  const pick = (l) => {
    if (window.setLang) window.setLang(l);
    force(n => n + 1);
    setTimeout(onClose, 250);
  };
  const names = window.LANG_NAMES || {};
  const entries = Object.entries(names)
    .filter(([c, n]) => !query || n.toLowerCase().includes(query.toLowerCase()) || c.toLowerCase().includes(query.toLowerCase()))
    // current language first, then alphabetical
    .sort(([a], [b]) => {
      if (a === cur) return -1;
      if (b === cur) return 1;
      return a.localeCompare(b);
    });
  const curName = names[cur] || cur;
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal lang-picker-v2" onClick={e=>e.stopPropagation()}>
        <div className="lp-ornament" aria-hidden="true"></div>
        <div className="lp-head">
          <div className="lp-title">
            <span className="lp-title-en">Language</span>
            <span className="lp-title-mark">·</span>
            <span className="lp-title-native">{curName}</span>
          </div>
          <button className="lp-close" onClick={onClose} aria-label="close">✕</button>
        </div>

        <div className="lp-search-row">
          <input type="text"
                 className="lp-search"
                 placeholder="search…"
                 value={query}
                 onChange={e => setQuery(e.target.value)}/>
        </div>

        <div className="lp-list">
          {entries.length === 0 && (
            <div className="lp-empty">— —</div>
          )}
          {entries.map(([code, name]) => (
            <button key={code}
                    type="button"
                    className={`lp-row ${cur === code ? 'active' : ''}`}
                    onClick={() => pick(code)}>
              <span className="lp-row-code">{code.toUpperCase()}</span>
              <span className="lp-row-name">{name}</span>
              {cur === code && <span className="lp-row-check">✓</span>}
            </button>
          ))}
        </div>

        <div className="lp-ornament bottom" aria-hidden="true"></div>
      </div>
    </div>
  );
}

/* ================= city message board (local for now · upgrade to Supabase) ================= */
const SEED_NAMES = ['Aiden','Yuki','Bo','Maya','Léo','Sofía','River','Mei','Kai','Chen','Nova','Ren'];
const SEED_MOODS = ['😌','😍','🤔','🥲','😎','🌧'];
const SEED_TOPICS_LABEL_ZH = { city:'城市', book:'书', music:'音乐', film:'电影', food:'美食' };
const SEED_TOPICS_LABEL_EN = { city:'City', book:'Book', music:'Music', film:'Film', food:'Food' };
const seedTopicLabel = (k) => {
  const lang = window.getLang ? window.getLang() : 'en';
  return (lang === 'zh' ? SEED_TOPICS_LABEL_ZH : SEED_TOPICS_LABEL_EN)[k] || k;
};
// keep zh map exposed for other call sites
const SEED_TOPICS_LABEL = SEED_TOPICS_LABEL_ZH;
const SEED_MESSAGES_BY_TOPIC_ZH = {
  city:[
    '一个人来这里走了三天，比想象中的更安静。',
    '凌晨四点的街角，一辆电车驶过。',
    '坐在咖啡店窗边一下午，看雨。',
  ],
  book:[
    '在车站的旧书摊翻到一本 80 年代的旅行随笔。',
    '机场免税店买了一本本地诗人的小诗集，押头韵很可爱。',
  ],
  music:[
    '本地电台午后放的爵士好戳我，想做一份歌单。',
    '广场上街头艺人在唱一首老歌，旋律一直在脑海打转。',
  ],
  film:[
    '入住的青旅大厅滚着一部讲这座城的纪录片，全程没字幕看完了。',
  ],
  food:[
    '路边那家小馆子人挤人，吃完出来浑身热。',
    '偶然推开一扇门，是这一周最好的一餐。',
  ],
};
const SEED_MESSAGES_BY_TOPIC_EN = {
  city:[
    'Walked around alone for three days. Quieter than I expected.',
    '4 AM on the corner — a single tram glides past.',
    'Spent a whole afternoon by the café window watching the rain.',
  ],
  book:[
    'Found an old travel essay collection from the 80s at a station bookstall.',
    'Picked up a slim book of local poetry at the airport — sweet alliterations.',
  ],
  music:[
    'Afternoon jazz on the local radio — building a playlist around it.',
    'A busker in the square sang an old song. The melody won\'t leave my head.',
  ],
  film:[
    'A documentary about this city was looping in the hostel lobby. Watched it without subtitles.',
  ],
  food:[
    'Tiny place packed with locals. Walked out with my face still warm.',
    'Pushed a random door open — best meal of the week.',
  ],
};
const SEED_MESSAGES_BY_TOPIC = SEED_MESSAGES_BY_TOPIC_ZH;
function seedMessagesPool() {
  const lang = window.getLang ? window.getLang() : 'en';
  return lang === 'zh' ? SEED_MESSAGES_BY_TOPIC_ZH : SEED_MESSAGES_BY_TOPIC_EN;
}
function seededMessagesFor(city) {
  // deterministic per city, localized to player's language
  const POOL = seedMessagesPool();
  let seed = 0;
  for (const c of city.id) seed = (seed * 31 + c.charCodeAt(0)) | 0;
  seed = Math.abs(seed);
  const out = [];
  let id = 1;
  for (const topic of Object.keys(POOL)) {
    const pool = POOL[topic];
    const pick = pool[(seed + id) % pool.length];
    out.push({
      id: `seed-${city.id}-${topic}-${id}`,
      cityId: city.id,
      topic,
      mood: SEED_MOODS[(seed + id) % SEED_MOODS.length],
      nickname: SEED_NAMES[(seed + id) % SEED_NAMES.length],
      content: pick,
      likes: ((seed * (id + 1)) % 80) + 3,
      seed: true,
      ts: Date.now() - ((id + seed % 7) * 86400000),
    });
    id++;
  }
  return out;
}

function MessageBoard({ city }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const auth = window.__conyAuth || {};
  const { user, profile, friendIds } = auth;
  const storeKey = `cony.msgs.${city.id}`;
  const seedKey = `cony.msgs.seed.${city.id}`;
  const likesKey = `cony.msgs.likes.${city.id}`;
  const repliesKey = `cony.msgs.replies.${city.id}`;
  const nickKey = 'cony.nickname';
  const supa = window.SUPA;
  const [online, setOnline] = useState(false);

  const [messages, setMessages] = useState(() => {
    try { return JSON.parse(localStorage.getItem(storeKey) || '[]'); } catch { return []; }
  });
  // fetch from Supabase + subscribe to realtime
  useEffect(() => {
    if (!supa) return;
    let cancelled = false;
    let channel;
    (async () => {
      const { data, error } = await supa
        .from('messages')
        .select('*')
        .eq('city_id', city.id)
        .order('created_at', { ascending: false })
        .limit(100);
      if (cancelled) return;
      if (error) { console.warn('msg fetch err', error); return; }
      setOnline(true);
      // map remote rows → local schema
      const remote = (data || []).map(r => ({
        id: `s-${r.id}`,
        cityId: r.city_id,
        topic: r.topic,
        mood: r.mood,
        nickname: r.nickname,
        content: r.content,
        likes: r.likes || 0,
        seed: false,
        remote: true,
        userId: r.user_id || null,
        ts: new Date(r.created_at).getTime(),
      }));
      setMessages(remote);
      try { localStorage.setItem(storeKey, JSON.stringify(remote)); } catch {}
    })();
    // realtime
    channel = supa.channel(`msg-${city.id}`)
      .on('postgres_changes',
          { event: 'INSERT', schema: 'public', table: 'messages', filter: `city_id=eq.${city.id}` },
          (payload) => {
            const r = payload.new;
            const newMsg = {
              id: `s-${r.id}`,
              cityId: r.city_id,
              topic: r.topic,
              mood: r.mood,
              nickname: r.nickname,
              content: r.content,
              likes: 0,
              seed: false,
              remote: true,
              userId: r.user_id || null,
              ts: new Date(r.created_at).getTime(),
            };
            setMessages(prev => [newMsg, ...prev.filter(m => m.id !== newMsg.id)]);
          })
      .subscribe();
    return () => {
      cancelled = true;
      if (channel) supa.removeChannel(channel);
    };
  }, [city.id]); // eslint-disable-line
  const [seedShuffleSeed] = useState(() => {
    let s = parseInt(localStorage.getItem(seedKey) || '0', 10);
    if (!s) { s = Math.floor(Math.random() * 9999); localStorage.setItem(seedKey, String(s)); }
    return s;
  });
  const seeded = useMemo(() => seededMessagesFor(city), [city.id]);
  const [likes, setLikes] = useState(() => {
    try { return JSON.parse(localStorage.getItem(likesKey) || '{}'); } catch { return {}; }
  });
  const [replies, setReplies] = useState(() => {
    try { return JSON.parse(localStorage.getItem(repliesKey) || '{}'); } catch { return {}; }
  });
  const [replyTo, setReplyTo] = useState(null);
  const [replyText, setReplyText] = useState('');

  const [topic, setTopic] = useState('city');
  const [mood, setMood] = useState('😌');
  const [content, setContent] = useState('');
  const [filter, setFilter] = useState('all');

  const all = useMemo(() => {
    const merged = [...messages, ...seeded];
    return merged.sort((a, b) => (b.ts || 0) - (a.ts || 0));
  }, [messages, seeded]);
  const visible = filter === 'all' ? all : all.filter(m => m.topic === filter);

  const toggleLike = (id) => {
    setLikes(prev => {
      const next = { ...prev, [id]: !prev[id] };
      try { localStorage.setItem(likesKey, JSON.stringify(next)); } catch {}
      return next;
    });
  };
  const submit = async () => {
    if (!content.trim()) return;
    const nick = localStorage.getItem(nickKey) || prompt('给自己起个旅人昵称吧（保存在浏览器，下次会沿用）：') || '匿名旅人';
    if (nick) localStorage.setItem(nickKey, nick);
    const body = content.trim();
    // try Supabase first; fall back to local
    if (supa) {
      const insertRow = {
        city_id: city.id,
        topic, mood,
        nickname: profile?.display_name || nick,
        content: body,
      };
      if (user) insertRow.user_id = user.id;
      const { data, error } = await supa.from('messages').insert(insertRow).select().single();
      if (!error && data) {
        // realtime channel will push it back; clear input
        setContent('');
        return;
      }
      console.warn('msg insert err, falling back to local:', error);
    }
    const msg = {
      id: `m-${Date.now()}-${Math.random().toString(36).slice(2,5)}`,
      cityId: city.id,
      topic, mood,
      nickname: nick,
      content: body,
      likes: 0,
      seed: false,
      ts: Date.now(),
    };
    const next = [msg, ...messages];
    setMessages(next);
    try { localStorage.setItem(storeKey, JSON.stringify(next)); } catch {}
    setContent('');
  };
  const submitReply = async (parentId) => {
    if (!replyText.trim()) return;
    const nick = localStorage.getItem(nickKey) || prompt('给自己起个旅人昵称：') || '匿名旅人';
    if (nick) localStorage.setItem(nickKey, nick);
    const body = replyText.trim();
    // remote reply if parent is a server message and supa available
    if (supa && typeof parentId === 'string' && parentId.startsWith('s-')) {
      const realParentId = parseInt(parentId.slice(2), 10);
      if (!isNaN(realParentId)) {
        const { data, error } = await supa.from('replies').insert({
          parent_id: realParentId, nickname: nick, content: body,
        }).select().single();
        if (!error && data) {
          const reply = {
            id: `sr-${data.id}`, nickname: data.nickname, content: data.content,
            ts: new Date(data.created_at).getTime(),
          };
          setReplies(prev => {
            const next = { ...prev, [parentId]: [...(prev[parentId] || []), reply] };
            try { localStorage.setItem(repliesKey, JSON.stringify(next)); } catch {}
            return next;
          });
          setReplyText(''); setReplyTo(null);
          return;
        }
        console.warn('reply insert err', error);
      }
    }
    // fallback local
    const reply = {
      id: `r-${Date.now()}-${Math.random().toString(36).slice(2,5)}`,
      nickname: nick,
      content: body,
      ts: Date.now(),
    };
    setReplies(prev => {
      const next = { ...prev, [parentId]: [...(prev[parentId] || []), reply] };
      try { localStorage.setItem(repliesKey, JSON.stringify(next)); } catch {}
      return next;
    });
    setReplyText('');
    setReplyTo(null);
  };

  const ago = (ts) => {
    const m = (Date.now() - ts) / 60000;
    if (lang === 'zh') {
      if (m < 1) return '刚刚';
      if (m < 60) return `${Math.floor(m)} 分钟前`;
      if (m < 24 * 60) return `${Math.floor(m / 60)} 小时前`;
      return `${Math.floor(m / 1440)} 天前`;
    }
    if (m < 1) return 'just now';
    if (m < 60) return `${Math.floor(m)}m ago`;
    if (m < 24 * 60) return `${Math.floor(m / 60)}h ago`;
    return `${Math.floor(m / 1440)}d ago`;
  };

  const MB = lang === 'zh' ? {
    tag:'旅人留言板', live:'🌐 公网', local:'💾 本地',
    titleLive:'已连接公网，新留言全球可见', titleLocal:'本地预览（未连后端）',
    all:'全部', empty:'这个分类还没有留言。', friend:'好友', mine:'我',
    cancel:'取消', reply:'回复', send:'发送', post:'发布留言',
    saveHint:'本地保存（待接入公网社区）',
    composePh: (n) => `写下你在${n}的留言…`,
    replyPh: (n) => `回复 ${n}…`,
  } : {
    tag:T('message.title'), live:'🌐 Online', local:'💾 Local',
    titleLive:'Live globally', titleLocal:'Local-only preview',
    all:'All', empty:'No messages here yet.', friend:'Friend', mine:'You',
    cancel:T('btn.cancel'), reply:T('btn.send'), send:T('btn.send'), post:T('message.send'),
    saveHint:'Saved locally',
    composePh: (n) => `Say something about ${n}…`,
    replyPh: (n) => `Reply to ${n}…`,
  };
  return (
    <div className="message-board" data-no-drag>
      <div className="mb-head">
        <span className="mb-tag">{MB.tag}</span>
        <span className={`mb-online ${online ? 'on' : 'off'}`} title={online ? MB.titleLive : MB.titleLocal}>
          {online ? MB.live : MB.local}
        </span>
        <div className="mb-filters">
          {['all','city','book','music','film','food'].map(t => (
            <button key={t} type="button"
                    className={`mb-filter ${filter === t ? 'active' : ''}`}
                    onClick={() => setFilter(t)}>
              {t === 'all' ? MB.all : seedTopicLabel(t)}
            </button>
          ))}
        </div>
      </div>
      <div className="mb-list">
        {visible.length === 0 && <div className="mb-empty">{MB.empty}</div>}
        {visible.map(m => {
          const liked = !!likes[m.id];
          const likeCount = (m.likes || 0) + (liked ? 1 : 0);
          const myReplies = replies[m.id] || [];
          const replying = replyTo === m.id;
          const isFriend = m.userId && friendIds && friendIds.has(m.userId);
          const isMine = user && m.userId === user.id;
          return (
            <div key={m.id} className={`mb-msg topic-${m.topic} ${isMine ? 'mine' : ''} ${isFriend ? 'friend' : ''} ${m.seed ? '' : 'real'}`}>
              <div className="mb-row1">
                <span className="mb-mood">{m.mood || '·'}</span>
                <span className="mb-nick">{m.nickname}</span>
                {isFriend && <span className="mb-friend-badge">{MB.friend}</span>}
                {isMine && <span className="mb-mine-badge">{MB.mine}</span>}
                <span className="mb-topic">{seedTopicLabel(m.topic) || m.topic}</span>
                <span className="mb-time">{ago(m.ts)}</span>
              </div>
              <div className="mb-content">{m.content}</div>
              <div className="mb-row2">
                <button className={`mb-like ${liked ? 'on' : ''}`} type="button" onClick={() => toggleLike(m.id)}>
                  ♥ {likeCount}
                </button>
                <button className="mb-reply-btn" type="button"
                        onClick={() => { setReplyTo(replying ? null : m.id); setReplyText(''); }}>
                  {replying ? MB.cancel : `${MB.reply}${myReplies.length ? ` (${myReplies.length})` : ''}`}
                </button>
              </div>
              {myReplies.length > 0 && (
                <div className="mb-replies">
                  {myReplies.map(r => (
                    <div key={r.id} className="mb-reply">
                      <span className="mbr-nick">{r.nickname}</span>
                      <span className="mbr-time">{ago(r.ts)}</span>
                      <div className="mbr-content">{r.content}</div>
                    </div>
                  ))}
                </div>
              )}
              {replying && (
                <div className="mb-reply-compose">
                  <textarea
                    value={replyText}
                    onChange={e => setReplyText(e.target.value)}
                    placeholder={MB.replyPh(m.nickname)}
                    rows={2}
                    maxLength={120}
                    className="mbrc-input"
                    autoFocus
                  />
                  <div className="mbrc-row">
                    <span className="mbrc-hint">{replyText.length}/120</span>
                    <button className="mbrc-send" type="button"
                            onClick={() => submitReply(m.id)} disabled={!replyText.trim()}>
                      {MB.send}
                    </button>
                  </div>
                </div>
              )}
            </div>
          );
        })}
      </div>
      <div className="mb-compose">
        <div className="mbc-row">
          <select value={topic} onChange={e => setTopic(e.target.value)} className="mbc-topic">
            {Object.keys(SEED_TOPICS_LABEL_ZH).map(k => <option key={k} value={k}>{seedTopicLabel(k)}</option>)}
          </select>
          <select value={mood} onChange={e => setMood(e.target.value)} className="mbc-mood">
            {SEED_MOODS.map(m => <option key={m} value={m}>{m}</option>)}
          </select>
        </div>
        <textarea
          value={content}
          onChange={e => setContent(e.target.value)}
          placeholder={MB.composePh(lang === 'zh' ? (city.name_zh || city.name) : (city.name || city.name_zh))}
          rows={2}
          maxLength={140}
          className="mbc-input"
        />
        <button className="mbc-send" type="button" onClick={submit} disabled={!content.trim()}>
          {MB.post}
        </button>
        <div className="mbc-hint">{content.length}/140 · {MB.saveHint}</div>
      </div>
    </div>
  );
}

/* ================= job board (working holiday) =================
 * Simple model:
 *   - 1 real hour ≈ 1 game second (so an 8h shift ≈ 8s in game)
 *   - Finish normally → 100% pay
 *   - Cancel early after ≥50% time elapsed → 50% pay
 *   - Cancel early before 50% → 0 pay (forfeit)                       */
function JobBoard({ city, balance, onEarn, onPayslip, pet }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  // REAL-TIME: 1 real hour = 1 hour. Persist in localStorage so closing the app
  // doesn't lose progress; user can come back later to a finished shift.
  // Literal real-world hours. Backpacker mechanic: 4h job → 4h wait.
  // Early-end at <50% = no pay, ≥50% = 50% pay, finish = full pay.
  const realHoursToMs = (h) => Math.max(60_000, h * 60 * 60 * 1000);
  const JOB_KEY = 'cony.job.' + city.id;
  const L = {
    start:    T('job.start'),
    cancel:   T('job.cancel'),
    forfeit:  T('job.forfeit'),
    half:     T('job.half'),
    full:     T('job.full'),
    flash:    (net) => `${T('job.payslip')} +$${net}`,
    flashHalf:(net) => `${T('job.payslipHalf')} +$${net}`,
    flashZero: T('job.forfeitFlash'),
  };

  // resume any saved job for this city on mount
  const [busyJob, setBusyJob] = useState(() => {
    try {
      const saved = JSON.parse(localStorage.getItem(JOB_KEY) || 'null');
      if (saved && saved.endMs > Date.now() - 1) return saved;
      return null;
    } catch { return null; }
  });
  const [progress, setProgress] = useState(0);
  const [doneFlash, setDoneFlash] = useState(null);

  useEffect(() => {
    if (!busyJob) return;
    // tick every second (real time)
    const id = setInterval(() => {
      const now = Date.now();
      const p = Math.min(1, (now - busyJob.startMs) / (busyJob.endMs - busyJob.startMs));
      setProgress(p);
      if (p >= 1) {
        clearInterval(id);
        localStorage.removeItem(JOB_KEY);
        const job = city.jobs.find(j => j.id === busyJob.jobId);
        if (job) {
          const slip = makePayslip({ city, job, pet });
          onPayslip?.(slip);
          onEarn?.(slip.netUSD);
          setDoneFlash({ id: busyJob.jobId, kind:'full', netUSD: slip.netUSD });
          setTimeout(() => setDoneFlash(null), 2800);
        }
        setBusyJob(null);
        setProgress(0);
      }
    }, 1000);
    return () => clearInterval(id);
  }, [busyJob, city]); // eslint-disable-line

  const work = (job) => {
    if (busyJob) return;
    const dur = realHoursToMs(job.hours);
    const start = Date.now();
    const payload = { jobId: job.id, startMs: start, endMs: start + dur };
    setBusyJob(payload);
    setProgress(0);
    try { localStorage.setItem(JOB_KEY, JSON.stringify(payload)); } catch {}
  };

  // End early: if ≥ 50% time elapsed → 50% pay; else 0 pay (forfeit).
  const endEarly = () => {
    if (!busyJob) return;
    localStorage.removeItem(JOB_KEY);
    const now = Date.now();
    const p = (now - busyJob.startMs) / (busyJob.endMs - busyJob.startMs);
    const job = city.jobs.find(j => j.id === busyJob.jobId);
    if (job && p >= 0.5) {
      // half pay
      const halfPay = Math.max(1, Math.round(job.payUSD * 0.5));
      const slip = makePayslip({ city, job: { ...job, payUSD: halfPay }, pet });
      onPayslip?.(slip);
      onEarn?.(slip.netUSD);
      setDoneFlash({ id: busyJob.jobId, kind:'half', netUSD: slip.netUSD });
    } else {
      // forfeit
      setDoneFlash({ id: busyJob.jobId, kind:'forfeit', netUSD: 0 });
    }
    setTimeout(() => setDoneFlash(null), 2800);
    setBusyJob(null);
    setProgress(0);
  };

  const remainMs = busyJob ? Math.max(0, busyJob.endMs - Date.now()) : 0;
  const remainStr = remainMs >= 1000 ? Math.ceil(remainMs / 1000) + 's' : '0s';
  const halfReached = busyJob && progress >= 0.5;

  return (
    <div className="job-board" data-no-drag>
      <div className="jb-head">
        <div className="jb-tag">WORKING HOLIDAY</div>
        <div className="jb-title">{T('section.jobs')} · {lang === 'zh' ? city.name_zh : city.name}</div>
      </div>
      <div className="jb-list">
        {city.jobs.map(j => {
          const busy = busyJob?.jobId === j.id;
          const done = doneFlash?.id === j.id;
          // localizeJob tries lang-specific first (e.g. JOB_I18N.ko), then JOB_I18N_EN, then raw
          const loc = (window.localizeJob ? window.localizeJob(j) : null) || { title: j.title, meta: j.meta };
          const title = loc.title;
          const meta = loc.meta;
          return (
            <div key={j.id} className={`job-card ${busy ? 'busy' : ''} ${done ? 'done' : ''}`}>
              <div className="jc-info">
                <div className="jc-title">{title}</div>
                <div className="jc-meta">{meta} · {j.hours}h</div>
                {busy && (
                  <div className="jc-progress">
                    <div className="jc-bar">
                      <div className="jc-fill" style={{ width: (progress * 100).toFixed(1) + '%' }}/>
                      <div className="jc-50-mark" aria-hidden="true"/>
                    </div>
                    <div className="jc-remain">
                      {remainStr} · {halfReached ? L.half : L.forfeit}
                    </div>
                  </div>
                )}
              </div>
              <div className="jc-pay">+${j.payUSD}</div>
              {!busy && !done && (
                <button className="jc-go" type="button" onClick={() => work(j)} disabled={!!busyJob}>
                  {L.start}
                </button>
              )}
              {busy && (
                <button className="jc-end" type="button" onClick={endEarly}>
                  {L.cancel}
                </button>
              )}
              {done && <div className="jc-done-mark">{doneFlash.kind === 'forfeit' ? '✕' : '✓'}</div>}
            </div>
          );
        })}
      </div>
      {doneFlash && (
        <div className={`jb-flash ${doneFlash.kind}`}>
          {doneFlash.kind === 'full'    && L.flash(doneFlash.netUSD)}
          {doneFlash.kind === 'half'    && L.flashHalf(doneFlash.netUSD)}
          {doneFlash.kind === 'forfeit' && L.flashZero}
        </div>
      )}
    </div>
  );
}

/* ================= class picker (world-level plane/ship) ================= */
function ClassPicker({ transportId, value, onChange }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'zh';
  const cabins = (window.CABIN_CLASSES && window.CABIN_CLASSES[transportId]) || [];
  if (cabins.length < 2) return null;
  const cabinName = (c) => lang === 'zh' ? c.name_zh : (c.name || c.name_zh);
  const cabinHint = (c) => lang === 'zh' ? c.hint : (window.CABIN_HINT_I18N?.[lang]?.[c.id] || c.hint);
  return (
    <div className="class-picker">
      <span className="cp-label">{T('depart.cabin')}</span>
      <div className="cp-pills">
        {cabins.map(c => (
          <button key={c.id} type="button"
                  className={`cp-pill ${value === c.id ? 'active' : ''}`}
                  onClick={() => onChange(c.id)}
                  title={cabinHint(c)}>
            <span className="cp-name">{cabinName(c)}</span>
            <span className="cp-hint">{cabinHint(c)}</span>
          </button>
        ))}
      </div>
    </div>
  );
}

/* ================= travel ticket + log ================= */
/* ================= reward popup · claim coins manually ================= */
/* ===== arrival popup · shown when a flight/transit reaches its destination ===== */
/* ===== upgrade-cabin modal · shown mid-flight when user clicks 升舱 =====
 * Lists faster cabin tiers with cost; player confirms before payment.
 * onConfirm(cabinId) is called on accept.                                   */
function UpgradeCabinModal({ upgrades, currentCabin, balance, transport, fromCity, toCity, onConfirm, onCancel }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const L = lang === 'zh' ? {
    title:'升舱加速', sub:'选择更高舱位，缩短剩余时间',
    current:'当前舱位', cost:'升级费用', insufficient:'余额不足',
    confirm:'确认升舱', cancel:'取消',
    saveTime:'省时',
  } : {
    title:'Upgrade cabin', sub:'Pick a faster cabin to shorten the flight',
    current:'Current', cost:'Upgrade cost', insufficient:'Insufficient',
    confirm:'Confirm upgrade', cancel:'Cancel',
    saveTime:'Time saved',
  };
  const [picked, setPicked] = useState(upgrades?.[0]?.id || null);
  if (!upgrades || upgrades.length === 0) return null;
  // calculate upgrade fee = base flight cost × (new priceMult − current priceMult)
  const baseCost = window.calcWorldCost
    ? window.calcWorldCost(fromCity, toCity, window.TRANSPORTS.find(x => x.id === transport), 'first') // First class as reference base
    : 0;
  // baseCost is calculated at FIRST class (priceMult 1). To get the "base price"
  // we need the raw base price (priceMult 0 → free, 1 → 100% base). Get base.
  const baseRaw = window.calcWorldCost
    ? (() => {
        // calcWorldCost returns 0 for economy. Use first class (priceMult 1) to get base.
        const c = window.CABIN_CLASSES?.[transport]?.find(x => x.id === 'first');
        if (!c) return 0;
        return window.calcWorldCost(fromCity, toCity, window.TRANSPORTS.find(x => x.id === transport), 'first');
      })()
    : 0;
  const costFor = (cabin) => Math.round(baseRaw * ((cabin.priceMult ?? 0) - (currentCabin?.priceMult ?? 0)));
  const timeSaved = (cabin) => {
    const cur = currentCabin?.timeMult ?? 1;
    const next = cabin.timeMult ?? 1;
    const pct = Math.round((cur - next) / cur * 100);
    return pct;
  };
  return (
    <div className="modal-overlay" onClick={onCancel}>
      <div className="modal upgrade-cabin-modal" onClick={e => e.stopPropagation()}>
        <div className="modal-head">
          <div className="modal-title">⏵ {L.title}</div>
          <button className="modal-close" onClick={onCancel}>×</button>
        </div>
        <div className="modal-summary">{L.sub}</div>
        <div className="modal-body">
          {currentCabin && (
            <div className="uc-current">
              <span className="uc-label">{L.current}</span>
              <span className="uc-name">{lang === 'zh' ? currentCabin.name_zh : (currentCabin.name || currentCabin.name_zh)}</span>
            </div>
          )}
          <div className="uc-options">
            {upgrades.map(cabin => {
              const cost = costFor(cabin);
              const saved = timeSaved(cabin);
              const cantAfford = cost > balance;
              return (
                <button key={cabin.id}
                        type="button"
                        className={`uc-option ${picked === cabin.id ? 'active' : ''} ${cantAfford ? 'disabled' : ''}`}
                        disabled={cantAfford}
                        onClick={() => setPicked(cabin.id)}>
                  <div className="uc-opt-head">
                    <span className="uc-opt-name">{lang === 'zh' ? cabin.name_zh : (cabin.name || cabin.name_zh)}</span>
                    <span className="uc-opt-cost">{cost === 0 ? '—' : '+$' + cost}</span>
                  </div>
                  <div className="uc-opt-meta">
                    <span className="uc-opt-saved">−{saved}% {L.saveTime}</span>
                    {cantAfford && <span className="uc-opt-warn">{L.insufficient}</span>}
                  </div>
                </button>
              );
            })}
          </div>
        </div>
        <div className="modal-actions">
          <button className="btn ghost" onClick={onCancel}>{L.cancel}</button>
          <button className="btn primary"
                  disabled={!picked || costFor(upgrades.find(c => c.id === picked)) > balance}
                  onClick={() => onConfirm(picked, costFor(upgrades.find(c => c.id === picked)))}>
            {L.confirm} → +${picked ? costFor(upgrades.find(c => c.id === picked)) : 0}
          </button>
        </div>
      </div>
    </div>
  );
}

/* ===== Visa application modal — needed when passport tier < destination tier ===== */
function VisaModal({ from, to, visa, balance, onCancel, onApply }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const [rolling, setRolling] = useState(false);
  const [result, setResult] = useState(null); // null | 'approved' | 'denied'
  const passportLabel = window.countryLabel ? window.countryLabel(from) : from;
  const destLabel = window.countryLabel ? window.countryLabel(to) : to;
  const flagFrom = window.flagFor ? window.flagFor(from) : '🌐';
  const flagTo   = window.flagFor ? window.flagFor(to)   : '🌐';
  const insufficient = visa.cost > balance;

  const apply = () => {
    if (insufficient) return;
    setRolling(true);
    setTimeout(() => {
      const approved = Math.random() >= visa.rejectRate;
      setResult(approved ? 'approved' : 'denied');
      setTimeout(() => {
        onApply(approved, visa.cost);
      }, 1400);
    }, 1200);
  };

  return (
    <div className="modal-overlay visa-overlay" onClick={!rolling ? onCancel : undefined}>
      <div className="modal visa-modal" onClick={e => e.stopPropagation()}>
        <div className="visa-head">
          <span className="visa-stamp">{T('visa.title') || 'Visa application'}</span>
        </div>
        <div className="visa-route">
          <div className="visa-end">
            <div className="visa-flag">{flagFrom}</div>
            <div className="visa-label">{T('visa.passport') || 'Passport'}</div>
            <div className="visa-country">{passportLabel}</div>
          </div>
          <div className="visa-arrow">→</div>
          <div className="visa-end">
            <div className="visa-flag">{flagTo}</div>
            <div className="visa-label">{T('visa.destination') || 'Entry'}</div>
            <div className="visa-country">{destLabel}</div>
          </div>
        </div>
        {result === null && (
          <>
            <div className="visa-stats">
              <div className="visa-row">
                <span className="visa-row-label">{T('visa.fee') || 'Application fee'}</span>
                <span className="visa-row-val">${visa.cost}</span>
              </div>
              <div className="visa-row">
                <span className="visa-row-label">{T('visa.reject') || 'Rejection rate'}</span>
                <span className="visa-row-val warn">{Math.round(visa.rejectRate * 100)}%</span>
              </div>
              <div className="visa-disclaimer">
                {T('visa.warn') || 'Fee is non-refundable if denied.'}
              </div>
            </div>
            <div className="visa-actions">
              <button className="btn ghost" type="button" onClick={onCancel} disabled={rolling}>
                {T('btn.cancel') || 'Cancel'}
              </button>
              <button className="btn primary" type="button" onClick={apply}
                      disabled={insufficient || rolling}>
                {insufficient ? (T('visa.poor') || 'Not enough funds')
                              : (rolling ? (T('visa.rolling') || 'Processing…')
                                         : (T('visa.apply') || `Apply · $${visa.cost}`))}
              </button>
            </div>
          </>
        )}
        {result === 'approved' && (
          <div className="visa-result approved">
            <div className="visa-result-stamp">✓ {T('visa.approved') || 'APPROVED'}</div>
            <div className="visa-result-text">{T('visa.approvedText') || 'Boarding cleared.'}</div>
          </div>
        )}
        {result === 'denied' && (
          <div className="visa-result denied">
            <div className="visa-result-stamp">✗ {T('visa.denied') || 'DENIED'}</div>
            <div className="visa-result-text">{T('visa.deniedText') || `Fee forfeited.`}</div>
          </div>
        )}
      </div>
    </div>
  );
}

/* ===== Game Over modal — full achievement summary ===== */
function GameOverModal({ visitedCountries, tickets, passportCountry, stats, balance, reason, onRebirth }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const ticketsArr = tickets || [];
  stats = stats || {};

  // Reason-specific subtitle. Falls back to the generic broke copy.
  const subByReason = (() => {
    if (reason === 'expired') {
      return lang === 'zh' ? '签证到期 · 60 天旅行期限已用完'
           : lang === 'ko' ? '비자 만료 · 60일 여행 기한 종료'
           : lang === 'ja' ? 'ビザ失効 · 60 日の旅行期限終了'
           : 'Visa expired · 60-day travel period is over';
    }
    if (reason === 'manual') {
      return lang === 'zh' ? '你主动结束了本局旅程'
           : lang === 'ko' ? '여행을 직접 종료하셨습니다'
           : lang === 'ja' ? 'あなたがこの旅を終わらせました'
           : 'You ended this run yourself';
    }
    if (reason === 'broke') {
      return lang === 'zh' ? '钱用光了，也没工作可做'
           : lang === 'ko' ? '잔액이 0이 되었고 진행 중인 일도 없습니다'
           : lang === 'ja' ? '所持金が尽き、進行中の仕事もありません'
           : 'You ran out of funds and have no job in progress';
    }
    return T('go.broke') || 'You ran out of funds.';
  })();

  // Compute derived stats
  const countries = (visitedCountries || []).slice().sort();
  const uniqueCities = new Set(ticketsArr.map(t => t.toId || t.to?.id).filter(Boolean));
  const flightCount = ticketsArr.length;
  const totalKm = ticketsArr.reduce((s, t) => s + (t.km || 0), 0);
  const totalSpent = ticketsArr.reduce((s, t) => s + (t.costUSD || 0) + (t.petFeeUSD || 0), 0);
  const visaTries = stats.visaAttempts || 0;
  const visaPass = stats.visaApprovals || 0;
  const visaFeesPaid = stats.visaFeesPaid || 0;
  const mediaTaps = stats.mediaTaps || 0;
  const visaSuccessRate = visaTries > 0 ? Math.round((visaPass / visaTries) * 100) : null;

  // Style of medal — based on achievements
  let medal = '🥉';
  let medalLabel = T('go.medalRookie') || 'Backpacker';
  if (countries.length >= 30 || flightCount >= 50) { medal = '🥇'; medalLabel = T('go.medalGold') || 'World Wanderer'; }
  else if (countries.length >= 15 || flightCount >= 25) { medal = '🥈'; medalLabel = T('go.medalSilver') || 'Seasoned Traveler'; }
  else if (countries.length >= 5 || flightCount >= 8) { medal = '🥉'; medalLabel = T('go.medalBronze') || 'Adventurer'; }
  else { medal = '🧳'; medalLabel = T('go.medalRookie') || 'Rookie'; }

  return (
    <div className="modal-overlay game-over-overlay">
      <div className="modal game-over-modal">
        <div className="go-header">
          <div className="go-stamp">{T('go.title') || 'GAME OVER'}</div>
          <div className="go-sub">{subByReason}</div>
        </div>

        <div className="go-medal-row">
          <div className="go-medal">{medal}</div>
          <div className="go-medal-info">
            <div className="go-medal-label">{medalLabel}</div>
            <div className="go-medal-passport">
              {window.flagFor ? window.flagFor(passportCountry) : '🌐'}{' '}
              {window.countryLabel ? window.countryLabel(passportCountry) : passportCountry}
              {' · T'}{window.getCountryTier ? window.getCountryTier(passportCountry) : '?'}
            </div>
          </div>
        </div>

        <div className="go-stats-grid">
          <div className="go-stat">
            <div className="go-stat-num">{countries.length}</div>
            <div className="go-stat-lbl">{T('go.countries') || 'countries'}</div>
          </div>
          <div className="go-stat">
            <div className="go-stat-num">{uniqueCities.size}</div>
            <div className="go-stat-lbl">{T('go.cities') || 'cities'}</div>
          </div>
          <div className="go-stat">
            <div className="go-stat-num">{flightCount}</div>
            <div className="go-stat-lbl">{T('go.flights') || 'flights'}</div>
          </div>
          <div className="go-stat">
            <div className="go-stat-num">{Math.round(totalKm).toLocaleString()}</div>
            <div className="go-stat-lbl">{T('go.km') || 'km'}</div>
          </div>
          <div className="go-stat">
            <div className="go-stat-num">${Math.round(totalSpent).toLocaleString()}</div>
            <div className="go-stat-lbl">{T('go.spent') || 'spent on travel'}</div>
          </div>
          <div className="go-stat">
            <div className="go-stat-num">{mediaTaps}</div>
            <div className="go-stat-lbl">{T('go.mediaTaps') || 'cultural cards'}</div>
          </div>
          {visaTries > 0 && (
            <>
              <div className="go-stat">
                <div className="go-stat-num">{visaPass}/{visaTries}</div>
                <div className="go-stat-lbl">{T('go.visas') || 'visas approved'}</div>
              </div>
              <div className="go-stat">
                <div className="go-stat-num">${visaFeesPaid.toLocaleString()}</div>
                <div className="go-stat-lbl">{T('go.visaFees') || 'visa fees'}</div>
              </div>
            </>
          )}
        </div>

        <div className="go-list-title">
          {T('go.passport') || 'Your passport stamps'}{' '}
          <span className="go-list-count">({countries.length})</span>
        </div>
        <div className="go-list">
          {countries.length === 0 ? (
            <div className="go-empty">{T('go.empty') || 'No stamps yet.'}</div>
          ) : countries.map(code => (
            <div key={code} className="go-stamp-row">
              <span className="go-stamp-flag">{window.flagFor ? window.flagFor(code) : '🌐'}</span>
              <span className="go-stamp-name">{window.countryLabel ? window.countryLabel(code) : code}</span>
            </div>
          ))}
        </div>

        <button className="btn primary go-rebirth" type="button" onClick={onRebirth}>
          ↺ {T('go.rebirth') || 'Rebirth'}
        </button>
      </div>
    </div>
  );
}

/* ===== Pathfinder · paywall ($2.99 one-time city unlock) =====
 * Triggers in two places (app.jsx): the player tapped Depart on a locked city,
 * or they've visited 5 distinct main cities (auto-popped once per run).
 *
 * BUY-FLOW STUB: clicking 解锁 currently just flips the entitlement locally.
 * Replace `onBuy` plumbing with StoreKit (iOS) / Stripe Checkout (web) once
 * the App Store product + Stripe keys exist. Receipt validation should write
 * back to the Supabase `entitlements` table so paid status survives device
 * changes — see the TODO note in the parent.
 */
function PaywallModal({ isPaid, onBuy, onClose }) {
  const lang = window.getLang ? window.getLang() : 'en';
  const [buying, setBuying] = useState(false);
  const [restoring, setRestoring] = useState(false);
  const hasNativeIAP = !!(window.cony?.iap?.buy);

  const handleBuy = () => {
    if (buying || isPaid) return;
    setBuying(true);
    if (hasNativeIAP) {
      // Native StoreKit path — onBuy is async and resolves once the Apple
      // sheet is dismissed (success, cancel, pending, or fail).
      Promise.resolve(onBuy?.()).finally(() => setBuying(false));
    } else {
      // Web stub: 600ms fake processing, flip locally.
      setTimeout(() => { setBuying(false); onBuy?.(); }, 600);
    }
  };

  const handleRestore = async () => {
    if (restoring || !hasNativeIAP) return;
    setRestoring(true);
    try {
      const r = await window.cony.iap.restore();
      if (r?.ok && (r.status === 'owned' || r.status === 'success')) {
        // Treat as a successful unlock — caller's onBuy mirrors that path.
        onBuy?.();
      } else {
        const m = lang === 'zh' ? '没有可恢复的购买。'
                : lang === 'ko' ? '복원할 구매가 없습니다.'
                : lang === 'ja' ? '復元できる購入はありません。'
                : 'No purchase to restore.';
        (window.conyAlert || window.alert)(m);
      }
    } catch (e) {
      const m = (lang === 'zh' ? '恢复失败：' : 'Restore failed: ') + (e?.message || e);
      (window.conyAlert || window.alert)(m, { tone: 'warn' });
    } finally {
      setRestoring(false);
    }
  };

  const copy = lang === 'zh' ? {
    tag: '一次性买断',
    title: '解锁全部城市',
    sub: '继续这段旅程，去 25 座尚未抵达的城市。',
    feats: [
      '🌍  解锁全部 37 座主城（免费版只 12 座）',
      '💰  起始金币提升到 $50,000（免费版 $20k）',
      '∞   无限新旅程，每一次都从头来过',
      '🌐  即将推出："长途旅行"环球模式',
    ],
    price: '$2.99',
    priceNote: '一次性，永久',
    buy: '解锁',
    later: '以后再说',
    buying: '正在处理…',
    success: '✓ 已解锁，去玩吧',
    restore: '已购买过？恢复',
    restoring: '正在恢复…',
  } : lang === 'ko' ? {
    tag: '일회성 결제',
    title: '모든 도시 잠금 해제',
    sub: '아직 가지 못한 25개 도시로 이어서 여행을 떠나세요.',
    feats: [
      '🌍  37개 주요 도시 전부 잠금 해제 (무료는 12개)',
      '💰  시작 잔액 $50,000 (무료는 $20k)',
      '∞   무제한 새 여정, 매번 처음부터',
      '🌐  곧 추가: "장거리 여행" 세계 일주 모드',
    ],
    price: '$2.99',
    priceNote: '일회성, 영구',
    buy: '잠금 해제',
    later: '나중에',
    buying: '처리 중…',
    success: '✓ 잠금 해제 완료',
    restore: '이미 구매하셨나요? 복원',
    restoring: '복원 중…',
  } : {
    tag: 'one-time unlock',
    title: 'Unlock all cities',
    sub: 'Continue the run — 25 more cities are waiting.',
    feats: [
      '🌍  All 37 main cities (free has 12)',
      '💰  Starting balance bumped to $50,000 (free: $20k)',
      '∞   Unlimited new runs, replay anytime',
      '🌐  Coming: "Long-distance" round-the-world mode',
    ],
    price: '$2.99',
    priceNote: 'one-time, forever',
    buy: 'Unlock',
    later: 'Maybe later',
    buying: 'Processing…',
    success: '✓ Unlocked',
    restore: 'Already bought? Restore',
    restoring: 'Restoring…',
  };

  return (
    <div className="modal-overlay paywall-overlay" onClick={onClose}>
      <div className="modal paywall-modal" onClick={e => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose} aria-label="close">×</button>
        <div className="pw-tag">{copy.tag}</div>
        <div className="pw-title">{copy.title}</div>
        <div className="pw-sub">{copy.sub}</div>

        <ul className="pw-feats">
          {copy.feats.map((f, i) => <li key={i} className="pw-feat">{f}</li>)}
        </ul>

        <div className="pw-price-row">
          <div className="pw-price">{copy.price}</div>
          <div className="pw-price-note">{copy.priceNote}</div>
        </div>

        <button
          className={`pw-buy ${buying ? 'busy' : ''} ${isPaid ? 'done' : ''}`}
          type="button"
          onClick={handleBuy}
          disabled={buying || isPaid}
        >
          {isPaid ? copy.success : (buying ? copy.buying : `${copy.buy} · ${copy.price}`)}
        </button>
        {hasNativeIAP && !isPaid && (
          <button
            className="pw-restore"
            type="button"
            onClick={handleRestore}
            disabled={restoring || buying}
          >
            {restoring ? copy.restoring : copy.restore}
          </button>
        )}
        <button className="pw-later" type="button" onClick={onClose}>{copy.later}</button>
      </div>
    </div>
  );
}

/* ===== Bonus claim modal · fires when a diary entry qualifies for $1000 ===== */
function BonusClaimModal({ amount, topic, queueSize, onClaim, onClose }) {
  const lang = window.getLang ? window.getLang() : 'en';
  const [claiming, setClaiming] = useState(false);
  const [done, setDone] = useState(false);

  const copy = lang === 'zh' ? {
    tag: '文化补贴',
    title: '心得已通过审核',
    sub: '你写的这篇内容符合奖励条件',
    aboutLbl: '关于',
    claim: '领取',
    claiming: '领取中…',
    later: '稍后再说',
    success: '✓ 已到账',
  } : lang === 'ko' ? {
    tag: '문화 보조금',
    title: '감상문 보조금 받기',
    sub: '이 글이 보조금 조건을 충족했습니다',
    aboutLbl: '대상',
    claim: '받기',
    claiming: '받는 중…',
    later: '나중에',
    success: '✓ 입금됨',
  } : lang === 'ja' ? {
    tag: '文化補助金',
    title: '感想が条件を満たしました',
    sub: 'この記事は補助金対象です',
    aboutLbl: '対象',
    claim: '受け取る',
    claiming: '受け取り中…',
    later: 'あとで',
    success: '✓ 入金完了',
  } : {
    tag: 'Culture stipend',
    title: 'Your entry qualifies!',
    sub: 'This piece meets the stipend conditions.',
    aboutLbl: 'About',
    claim: 'Claim',
    claiming: 'Claiming…',
    later: 'Later',
    success: '✓ Credited',
  };

  const handleClaim = () => {
    if (claiming || done) return;
    setClaiming(true);
    setTimeout(() => {
      onClaim?.();
      setDone(true);
      setTimeout(() => onClose?.(), 900);
    }, 320);
  };

  return (
    <div className="modal-overlay bonus-claim-overlay" onClick={onClose}>
      <div className={`modal bonus-claim-modal ${done ? 'claimed' : ''}`} onClick={e => e.stopPropagation()}>
        <div className="bc-orn" aria-hidden="true">✨</div>
        <div className="bc-tag">{copy.tag}</div>
        <div className="bc-title">{copy.title}</div>
        <div className="bc-sub">{copy.sub}</div>
        {queueSize > 1 && (
          <div className="bc-queue">
            {lang === 'zh' ? `还有 ${queueSize - 1} 笔待领取`
              : lang === 'ko' ? `${queueSize - 1}개 더 대기 중`
              : lang === 'ja' ? `他に ${queueSize - 1} 件待機中`
              : `${queueSize - 1} more pending`}
          </div>
        )}

        <div className="bc-amount">
          <span className="bc-currency">+$</span>
          <span className="bc-num">{(amount || 1000).toLocaleString()}</span>
        </div>

        {topic && (
          <div className="bc-topic">
            <span className="bc-topic-lbl">{copy.aboutLbl}</span>
            <span className="bc-topic-val">「{topic}」</span>
          </div>
        )}

        {done ? (
          <div className="bc-success">{copy.success}</div>
        ) : (
          <div className="bc-actions">
            <button className="btn ghost bc-later" type="button" onClick={onClose} disabled={claiming}>
              {copy.later}
            </button>
            <button className={`btn primary bc-claim ${claiming ? 'busy' : ''}`} type="button" onClick={handleClaim} disabled={claiming}>
              {claiming ? copy.claiming : `${copy.claim} · +$${amount || 1000}`}
            </button>
          </div>
        )}
      </div>
    </div>
  );
}

/* ===== Generic styled confirm / alert · replaces native browser dialogs =====
 * Drives both window.conyConfirm() and window.conyAlert(). Component renders
 * the head of the dialog queue maintained in app.jsx. Returns a Promise
 * resolving to true (ok) / false (cancel). Tones: normal | warn | danger. */
function ConyDialog({ dialog, onResolve }) {
  if (!dialog) return null;
  const lang = window.getLang ? window.getLang() : 'en';
  const isConfirm = dialog.type === 'confirm';
  const isDanger = dialog.tone === 'danger';
  const okText = dialog.okText || (
    lang === 'zh' ? '确定' :
    lang === 'ko' ? '확인' :
    lang === 'ja' ? 'OK' : 'OK'
  );
  const cancelText = dialog.cancelText || (
    lang === 'zh' ? '取消' :
    lang === 'ko' ? '취소' :
    lang === 'ja' ? 'キャンセル' : 'Cancel'
  );
  return (
    <div className="modal-overlay cony-dialog-overlay"
         onClick={!isConfirm ? () => onResolve(true) : undefined}>
      <div className={`modal cony-dialog tone-${dialog.tone || 'normal'}`}
           onClick={e => e.stopPropagation()}>
        {dialog.title && <div className="cd-title">{dialog.title}</div>}
        <div className="cd-msg">{dialog.msg}</div>
        <div className="cd-actions">
          {isConfirm && (
            <button className="btn ghost" type="button" onClick={() => onResolve(false)}>{cancelText}</button>
          )}
          <button
            className={`btn primary ${isDanger ? 'danger' : ''}`}
            type="button"
            onClick={() => onResolve(true)}
            autoFocus
          >{okText}</button>
        </div>
      </div>
    </div>
  );
}

/* ===== claim funds popup · shown once on first launch ===== */
function ClaimFundsModal({ onClaim }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const [claimed, setClaimed] = useState(false);
  const handleClaim = () => {
    if (claimed) return;
    setClaimed(true);
    setTimeout(() => onClaim?.(), 700);
  };
  return (
    <div className="modal-overlay" onClick={(e) => e.stopPropagation()}>
      <div className={`modal claim-funds-modal ${claimed ? 'claimed' : ''}`} onClick={e => e.stopPropagation()}>
        <div className="cf-ornament" aria-hidden="true"></div>
        <div className="cf-icon">✈</div>
        <div className="cf-tag">{T('funds.sub')}</div>
        <div className="cf-title">{T('funds.title')}</div>
        <div className="cf-amount">
          <span className="cf-currency">$</span>
          <span className="cf-num">20,000</span>
        </div>
        <div className="cf-tip">{T('funds.tip')}</div>
        <button className={`cf-btn ${claimed ? 'going' : ''}`} type="button" onClick={handleClaim} disabled={claimed}>
          {claimed ? '✓' : T('funds.claim')}
        </button>
        <div className="cf-ornament bottom" aria-hidden="true"></div>
      </div>
    </div>
  );
}

function ArrivalPopup({ city, transport, durationLabel, onContinue }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  if (!city) return null;
  const cityName = lang === 'zh' ? (city.name_zh || city.name) : (city.name || city.name_zh);
  const M = lang === 'zh' ? {
    tag:'已抵达', sub:'本次旅程目的地已到达', cta:'继续探索',
    via:'交通方式', duration:'用时',
  } : lang === 'ko' ? {
    tag:'도착', sub:'목적지에 도착했습니다', cta:'둘러보기',
    via:'교통편', duration:'소요시간',
  } : lang === 'ja' ? {
    tag:'到着', sub:'目的地に到着しました', cta:'探索する',
    via:'交通手段', duration:'所要時間',
  } : {
    tag:'Arrived', sub:'You have reached your destination', cta:'Explore',
    via:'Via', duration:'Duration',
  };
  return (
    <div className="modal-overlay" onClick={onContinue}>
      <div className="modal arrival-popup" onClick={e => e.stopPropagation()}>
        <div className="ap-flag">{flagOf(city.country)}</div>
        <div className="ap-tag">✈ {M.tag}</div>
        <div className="ap-city">{cityName}</div>
        <div className="ap-sub">{M.sub}</div>
        {(transport || durationLabel) && (
          <div className="ap-meta">
            {transport && <span>{M.via} · {T(`trans.${transport}`) || transport}</span>}
            {durationLabel && <span>{M.duration} · {durationLabel}</span>}
          </div>
        )}
        <button className="btn primary ap-cta" onClick={onContinue}>{M.cta} →</button>
      </div>
    </div>
  );
}

function RewardPopup({ reward, queueSize, onClaim }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const [claimed, setClaimed] = useState(false);
  const handleClaim = () => {
    if (claimed) return;
    setClaimed(true);
    setTimeout(() => onClaim?.(), 700);
  };
  const queueHint = lang === 'zh'
    ? `还有 ${queueSize - 1} 笔待领`
    : `${queueSize - 1} more`;
  return (
    <div className="reward-popup-backdrop" onClick={handleClaim}>
      <div className={`reward-popup ${claimed ? 'claimed' : ''}`} onClick={e => e.stopPropagation()}>
        <div className="rp-tag">REWARD</div>
        <div className="rp-amount">
          <span className="rp-plus">+</span>
          <span className="rp-num">${reward.amount}</span>
        </div>
        {reward.source && <div className="rp-source">{reward.source}</div>}
        {queueSize > 1 && (
          <div className="rp-queue-hint">{queueHint}</div>
        )}
        <button className={`rp-claim-btn ${claimed ? 'going' : ''}`} type="button" onClick={handleClaim} disabled={claimed}>
          {claimed ? `✓ ${T('common.success')}` : T('btn.confirm')}
        </button>
      </div>
    </div>
  );
}

/* ================= depart confirm modal (boarding-pass preview) ================= */
function DepartConfirm({ from, to, transport, cabinClass, pet, scope, balance, countryId, onConfirm, onCancel }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const cityName = (c) => lang === 'zh' ? (c.name_zh || c.name) : (c.name || c.name_zh);
  const auth = window.__conyAuth || {};
  const friends = auth.friends || [];
  const [invitedIds, setInvitedIds] = useState([]);
  const toggleInvite = (fid) => {
    setInvitedIds(prev => prev.includes(fid) ? prev.filter(x => x !== fid) : [...prev, fid]);
  };
  const invitedFriends = friends.filter(f => invitedIds.includes(f.id));
  const cabin = (window.CABIN_CLASSES?.[transport.id] || []).find(c => c.id === cabinClass);
  const cost = scope === 'domestic'
    ? (window.calcDomesticCost?.(from, to, transport, cabinClass, countryId) || 0)
    : (window.calcWorldCost?.(from, to, transport, cabinClass) || 0);
  const petFee = (pet && window.calcPetFee) ? window.calcPetFee(transport.id, cost) : 0;
  const total = cost + petFee;
  const cur = window.COUNTRY_CURRENCY?.[(to.country || countryId)];
  const localTotal = (cur && cur.code !== 'USD')
    ? (cur.decimals === 0
        ? `${cur.symbol}${Math.round(total * cur.perUSD).toLocaleString()}`
        : `${cur.symbol}${(total * cur.perUSD).toFixed(cur.decimals)}`)
    : null;
  const insufficient = balance != null && total > balance && balance < 1000000;
  const code = transport.id.slice(0, 2).toUpperCase();
  const flightNo = `${code}${Math.floor(Math.random() * 9000 + 1000)}`;
  const seat = `${Math.floor(Math.random() * 30 + 1)}${'ABCDEF'[Math.floor(Math.random() * 6)]}`;
  const tlabel = { plane:'BOARDING PASS', ship:'FERRY TICKET', subway:'RAIL TICKET', bus:'BUS TICKET' }[transport.id] || 'TICKET';

  return (
    <div className="modal-overlay" onClick={onCancel}>
      <div className="modal depart-confirm" onClick={e => e.stopPropagation()}>
        <div className="dc-head">
          <div className="dc-tag">{T('depart.title')} · CONFIRM DEPARTURE</div>
        </div>
        <div className={`dc-ticket tk-${transport.id}`}>
          <div className="dc-stub">
            <div className="dc-stub-meta">{tlabel}</div>
            <div className="dc-stub-no">{flightNo}</div>
            <div className="dc-stub-bar"></div>
          </div>
          <div className="dc-main">
            <div className="dc-route">
              <div className="dc-side">
                <div className="dc-tag-sm">FROM</div>
                <div className="dc-city">{cityName(from)}</div>
              </div>
              <div className="dc-arrow">———▸</div>
              <div className="dc-side">
                <div className="dc-tag-sm">TO</div>
                <div className="dc-city">{cityName(to)}</div>
              </div>
            </div>
            <div className="dc-row">
              <div><span className="dc-l">{T('section.transport')}</span><span className="dc-v">{T(`trans.${transport.id}`)}</span></div>
              {cabin && (window.CABIN_CLASSES?.[transport.id] || []).length > 1 && (
                <div><span className="dc-l">{T('depart.cabin')}</span><span className="dc-v">{lang === 'zh' ? cabin.name_zh : (cabin.name || cabin.name_zh)}</span></div>
              )}
              <div><span className="dc-l">SEAT</span><span className="dc-v">{seat}</span></div>
            </div>
            <div className="dc-fare">
              <div className="dc-fare-row">
                <span>{T('depart.fare')}</span><span>${cost}</span>
              </div>
              {petFee > 0 && (
                <div className="dc-fare-row">
                  <span>{T('pet.title')}</span><span>+${petFee}</span>
                </div>
              )}
              <div className="dc-fare-row total">
                <span>{T('depart.balance_after')}</span>
                <span>
                  <strong>${total}</strong>
                  {localTotal && <em className="dc-local">≈ {localTotal}</em>}
                </span>
              </div>
            </div>
            {pet && (
              <div className="dc-pet">
                {pet.photo
                  ? <img src={pet.photo} className="dc-pet-photo" alt=""/>
                  : <span className="dc-pet-icon">🐾</span>}
                <span>{T('pet.title')} · {pet.name}</span>
              </div>
            )}
          </div>
        </div>
        {insufficient && (
          <div className="dc-warn">{T('common.error')} · ${total - balance}</div>
        )}
        {friends.length > 0 && (
          <div className="dc-invite">
            <div className="dc-invite-tag">{T('depart.invite')}</div>
            <div className="dc-invite-sub">
              {lang === 'zh' ? '点击头像选择同行好友' :
               lang === 'ko' ? '함께 갈 친구를 탭하세요' :
               lang === 'ja' ? '同行する友達をタップ' :
               'Tap a friend to invite them along'}
            </div>
            <div className="dc-invite-list">
              {friends.map(f => {
                const on = invitedIds.includes(f.id);
                return (
                  <button key={f.id}
                          type="button"
                          className={`dc-invite-pill ${on ? 'on' : ''}`}
                          onClick={() => toggleInvite(f.id)}>
                    <span className="dcip-avatar">{(f.display_name || '?').charAt(0)}</span>
                    <span className="dcip-name">{f.display_name}</span>
                    <span className="dcip-mark" aria-hidden="true">{on ? '✓' : '+'}</span>
                  </button>
                );
              })}
            </div>
            {invitedIds.length > 0 && (
              <div className="dc-invite-hint">
                ✓ {invitedFriends.map(f => f.display_name).join(' · ')} {lang === 'zh' ? '同行' : lang === 'ko' ? '동행' : lang === 'ja' ? '同行' : 'joining'}
              </div>
            )}
          </div>
        )}
        <div className="dc-actions">
          <button className="btn ghost" onClick={onCancel}>{T('btn.cancel')}</button>
          <button className="btn primary dc-go" onClick={() => onConfirm?.(invitedFriends)} disabled={insufficient}>
            {T('btn.depart')} →
          </button>
        </div>
      </div>
    </div>
  );
}

function makeTicket(opts) {
  const { from, to, transport, cabinClassId, scope, countryId, pet, costUSD, petFeeUSD, companions } = opts;
  const cabin = (window.CABIN_CLASSES && window.CABIN_CLASSES[transport.id] || []).find(c => c.id === cabinClassId);
  const code = transport.id.slice(0,2).toUpperCase();
  return {
    id: `T-${Date.now()}-${Math.random().toString(36).slice(2,6)}`,
    fromName: from.name_zh || from.name,
    toName: to.name_zh || to.name,
    fromId: from.id, toId: to.id,
    fromCountry: from.country || countryId || null,
    toCountry: to.country || countryId || null,
    transportId: transport.id,
    transportName: transport.name,
    cabinClassId: cabin?.id || null,
    cabinClassName: cabin?.name_zh || null,
    scope,
    countryId: countryId || null,
    flightNo: `${code}${Math.floor(Math.random()*9000+1000)}`,
    seat: `${Math.floor(Math.random()*30+1)}${'ABCDEF'[Math.floor(Math.random()*6)]}`,
    departedAt: new Date().toISOString(),
    costUSD: costUSD || 0,
    petFeeUSD: petFeeUSD || 0,
    pet: pet ? { name: pet.name, species: pet.species, photo: pet.photo || '' } : null,
    companions: companions || [],
  };
}

/* ===== achievements · 成就系统 ===== */
const ACHIEVEMENTS = [
  { id:'first-trip',   name:'First Departure',  name_zh:'初次启程',     desc:'Take your first trip',                    desc_zh:'完成第一次出行',         icon:'✈',  cond:s => s.tickets >= 1 },
  { id:'5-countries',  name:'5-Country Visa',   name_zh:'五国签证',     desc:'Visit 5 countries',                       desc_zh:'访问 5 个国家',          icon:'🌍', cond:s => s.countries >= 5 },
  { id:'10-countries', name:'10-Country Tour',  name_zh:'十国巡礼',     desc:'Visit 10 countries',                      desc_zh:'访问 10 个国家',         icon:'🗺',  cond:s => s.countries >= 10 },
  { id:'20-countries', name:'Half the Globe',   name_zh:'地球半圈',     desc:'Visit 20 countries',                      desc_zh:'访问 20 个国家',         icon:'🌐', cond:s => s.countries >= 20 },
  { id:'all-transport',name:'All Transports',   name_zh:'全交通玩家',   desc:'Try plane / rail / bus / ship',           desc_zh:'体验飞机/高铁/大巴/船',  icon:'🚏', cond:s => s.transports.size >= 4 },
  { id:'first-class',  name:'First-Class Flyer',name_zh:'头等舱常客',   desc:'First class / suite × 3',                 desc_zh:'坐头等舱/套房 3 次',     icon:'💺', cond:s => s.firstClass >= 3 },
  { id:'rich-worker',  name:'Working Holiday',  name_zh:'打工人',       desc:'Earn $1000 from work',                    desc_zh:'打工累计赚 $1000',       icon:'🧺', cond:s => s.totalEarned >= 1000 },
  { id:'task-master',  name:'City Master',      name_zh:'城市达人',     desc:'Complete 5 city tasks',                   desc_zh:'完成 5 张城市任务',      icon:'✓',  cond:s => s.tasksDone >= 5 },
  { id:'photo-rookie', name:'Shutterbug',       name_zh:'快门旅人',     desc:'10 photos in your album',                 desc_zh:'相册 10 张照片',         icon:'📷', cond:s => s.photos >= 10 },
  { id:'diarist',      name:'The Diarist',      name_zh:'写日子的人',   desc:'Write 5 diary entries',                   desc_zh:'写 5 篇日记',            icon:'✎',  cond:s => s.diaryEntries >= 5 },
  { id:'pet-lover',    name:'Pet Companion',    name_zh:'带宠物旅人',   desc:'5 trips with a pet',                      desc_zh:'带宠物完成 5 次旅程',    icon:'🐾', cond:s => s.tripsWithPet >= 5 },
  { id:'distance-3k',  name:'3,000 km Flown',   name_zh:'三千公里',     desc:'Cumulative 3,000 km',                     desc_zh:'累计飞行 3000 公里',     icon:'⟿',  cond:s => s.totalKm >= 3000 },
];
function computeStats({ tickets, payslips, photos, diary }) {
  const countries = new Set();
  const transports = new Set();
  let firstClass = 0, totalKm = 0, tripsWithPet = 0;
  for (const t of tickets) {
    if (t.toCountry) countries.add(t.toCountry);
    if (t.transportId) transports.add(t.transportId);
    if (t.cabinClassId === 'first' || t.cabinClassId === 'suite') firstClass++;
    if (t.pet) tripsWithPet++;
    // very rough: assume 800km per ticket (we don't store km)
    totalKm += 800;
  }
  const totalEarned = payslips.reduce((s, p) => s + (p.netUSD || 0), 0);
  // tasksDone: count localStorage keys cony.tasks.* with __rewardClaimed
  let tasksDone = 0;
  try {
    for (const k of Object.keys(localStorage)) {
      if (k.startsWith('cony.tasks.')) {
        const v = JSON.parse(localStorage.getItem(k) || '{}');
        if (v.__rewardClaimed) tasksDone++;
      }
    }
  } catch {}
  return {
    tickets: tickets.length,
    countries: countries.size,
    transports,
    firstClass,
    totalEarned,
    tasksDone,
    photos: photos.length,
    diaryEntries: diary.length,
    tripsWithPet,
    totalKm,
  };
}
function Achievements({ tickets, payslips, photos, diary }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const stats = computeStats({ tickets, payslips, photos, diary });
  const unlocked = ACHIEVEMENTS.filter(a => a.cond(stats));
  const locked   = ACHIEVEMENTS.filter(a => !a.cond(stats));
  const aName = (a) => lang === 'zh' ? (a.name_zh || a.name) : a.name;
  const aDesc = (a) => lang === 'zh' ? (a.desc_zh || a.desc) : a.desc;
  return (
    <div className="achievements">
      <div className="ach-summary">
        {T('top.achievements')} <strong>{unlocked.length}</strong> / {ACHIEVEMENTS.length}
      </div>
      <div className="ach-grid">
        {[...unlocked, ...locked].map(a => {
          const isUnlocked = unlocked.includes(a);
          return (
            <div key={a.id} className={`ach-item ${isUnlocked ? 'on' : 'off'}`}>
              <div className="ach-icon">{a.icon}</div>
              <div className="ach-name">{aName(a)}</div>
              <div className="ach-desc">{aDesc(a)}</div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

/* ===== weather delay · 30 分钟延误时改方案 ===== */
function WeatherDelayModal({ from, to, transport, weatherCode, onWait, onCancel, onChangeTransport }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const codeLabels = lang === 'zh' ? {
    61:'雨', 63:'中雨', 65:'大雨', 71:'雪', 73:'雪', 75:'大雪',
    80:'阵雨', 81:'阵雨', 82:'暴雨', 85:'阵雪', 86:'阵雪',
    95:'雷雨', 96:'雷雨', 99:'雷暴',
  } : {
    61:'rain', 63:'rain', 65:'heavy rain', 71:'snow', 73:'snow', 75:'heavy snow',
    80:'showers', 81:'showers', 82:'storm', 85:'snow showers', 86:'snow showers',
    95:'thunderstorm', 96:'thunderstorm', 99:'thunderstorm',
  };
  const W = lang === 'zh' ? {
    badWeather:'坏天气', delayed:'延误',
    sub:'航班/班次延误 30 分钟（约 +30 秒游戏时间）',
    cancel:'取消行程', refund:'退 50% 票款',
    change:'改其他交通',
    wait:'继续等待', plus:'+30 分钟',
  } : {
    badWeather:'bad weather', delayed:'delayed',
    sub:'Flight delayed 30 min (~ +30 sec game time)',
    cancel:'Cancel trip', refund:'50% refund',
    change:'Change transport',
    wait:'Keep waiting', plus:'+30 min',
  };
  const cityLbl = lang === 'zh' ? (to.name_zh || to.name) : (to.name || to.name_zh);
  const label = codeLabels[weatherCode] || W.badWeather;
  const icon = weatherCode >= 95 ? '⛈' : weatherCode >= 71 ? '❄' : '🌧';
  return (
    <div className="modal-overlay" onClick={onCancel}>
      <div className="modal weather-delay" onClick={e => e.stopPropagation()}>
        <div className="wd-icon">{icon}</div>
        <div className="wd-title">{cityLbl} · {label} {W.delayed}</div>
        <div className="wd-sub">{W.sub}</div>
        <div className="wd-actions">
          <button className="btn ghost" onClick={onCancel}>{W.cancel}<br/><span className="dim">{W.refund}</span></button>
          <button className="btn ghost" onClick={onChangeTransport}>{W.change}</button>
          <button className="btn primary" onClick={onWait}>{W.wait}<br/><span className="dim">{W.plus}</span></button>
        </div>
      </div>
    </div>
  );
}

/* ===== travel companion NPC ===== */
const COMPANIONS = [
  { id:'laozhang', name:'老张',    avatar:'👴', tag:'退休记者',     style:'冷幽默',
    comments:['这里 1995 年我也来过 …', '老规矩，下飞机先找便利店', '又下雨，我的伞呢', '今天的云像 1987 年那张照片'] },
  { id:'yuki',     name:'Yuki',    avatar:'🧑‍🎤', tag:'J-Pop 迷',     style:'欢乐',
    comments:['我已经在脑里循环 LiSA 了！', '这家拉面店一定要排队', '哇～新干线最棒了', '回去要发 IG'] },
  { id:'leo',      name:'Léo',     avatar:'🧑‍🍳', tag:'巴黎厨师',     style:'美食评论员',
    comments:['这里的面包发酵不够', '橄榄油不够好', '这趟值得为了那只可颂', '味道，是记忆的钥匙'] },
  { id:'maya',     name:'Maya',    avatar:'👩‍🎨', tag:'摄影师',       style:'话不多',
    comments:['光线刚好。', '我在拍后脑勺。', '这个角度 …', '又拍了 100 张。'] },
  { id:'river',    name:'River',   avatar:'🧑',     tag:'青年诗人',     style:'文艺',
    comments:['路是为离开的人留下的。', '风把昨天吹散了。', '想在这里写一首', '如果时间是水，我就站在岸上。'] },
  { id:'kai',      name:'Kai',     avatar:'🏄', tag:'夏威夷冲浪',   style:'阳光',
    comments:['Mate，这一波浪不错', '回程订一晚上 BBQ', '海风太香了', 'Aloha~'] },
];
function CompanionPicker({ active, onChange, onClose }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  // English translations of NPC tag/style fields
  const TAGS = {
    '退休记者':'Retired journalist','J-Pop 迷':'J-Pop fan','巴黎厨师':'Paris chef',
    '摄影师':'Photographer','青年诗人':'Young poet','夏威夷冲浪':'Hawaii surfer',
  };
  const STYLES = {
    '冷幽默':'Dry humor','欢乐':'Cheerful','美食评论员':'Foodie',
    '话不多':'Quiet','文艺':'Literary','阳光':'Sunny',
  };
  const NAMES = { '老张':'Lao Zhang' };
  const cName = (c) => lang === 'zh' ? c.name : (NAMES[c.name] || c.name);
  const cTag = (c) => lang === 'zh' ? c.tag : (TAGS[c.tag] || c.tag);
  const cStyle = (c) => lang === 'zh' ? c.style : (STYLES[c.style] || c.style);
  const max = lang === 'zh' ? '最多招募 2 位旅伴' : 'Up to 2 companions';
  const summary = lang === 'zh'
    ? <>已选 <strong>{active.length}</strong> / 2 · 旅途中他们会偶尔评论</>
    : <>Selected <strong>{active.length}</strong> / 2 · they comment along the way</>;
  const toggle = (id) => {
    const has = active.includes(id);
    if (has) onChange(active.filter(x => x !== id));
    else if (active.length < 2) onChange([...active, id]);
    else (window.conyAlert || window.alert)(max);
  };
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal companion-modal" onClick={e => e.stopPropagation()}>
        <div className="modal-head">
          <div className="modal-title">{T('top.companion')}</div>
          <button className="modal-close" onClick={onClose}>×</button>
        </div>
        <div className="modal-summary">
          {summary}
        </div>
        <div className="modal-body">
          <div className="comp-grid">
            {COMPANIONS.map(c => (
              <div key={c.id} className={`comp-card ${active.includes(c.id) ? 'on' : ''}`}
                   onClick={() => toggle(c.id)}>
                <div className="comp-avatar">{c.avatar}</div>
                <div className="comp-name">{cName(c)}</div>
                <div className="comp-tag">{cTag(c)}</div>
                <div className="comp-style">{cStyle(c)}</div>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}
function CompanionChatter({ activeIds, progress }) {
  // emit one comment around 30% and another around 70%
  const [shown, setShown] = useState(null);
  useEffect(() => {
    if (activeIds.length === 0) return;
    const triggers = [0.25, 0.55, 0.85];
    for (const tr of triggers) {
      if (Math.abs(progress - tr) < 0.02) {
        const npc = COMPANIONS.find(c => c.id === activeIds[Math.floor(Math.random() * activeIds.length)]);
        if (!npc) return;
        const line = npc.comments[Math.floor(Math.random() * npc.comments.length)];
        setShown({ npc, line, ts: Date.now() });
        const t = setTimeout(() => setShown(null), 4500);
        return () => clearTimeout(t);
      }
    }
  }, [Math.floor(progress * 100)]); // eslint-disable-line
  if (!shown) return null;
  return (
    <div className="comp-chatter">
      <div className="cc-avatar">{shown.npc.avatar}</div>
      <div className="cc-bubble">
        <div className="cc-name">{shown.npc.name}</div>
        <div className="cc-line">{shown.line}</div>
      </div>
    </div>
  );
}

/* ===== shared country code → ISO2 / flag emoji helpers ===== */
window.ISO2 = window.ISO2 || {
  JPN:'JP', CHN:'CN', KOR:'KR', USA:'US', GBR:'GB', FRA:'FR', ITA:'IT', DEU:'DE',
  ESP:'ES', PRT:'PT', NLD:'NL', RUS:'RU', GRC:'GR', MEX:'MX', BRA:'BR', ARG:'AR',
  EGY:'EG', ZAF:'ZA', AUS:'AU', NZL:'NZ', HKG:'HK', TWN:'TW', THA:'TH', SGP:'SG',
  IND:'IN', ARE:'AE', TUR:'TR',
  // newly-added countries
  VNM:'VN', IDN:'ID', PHL:'PH', MYS:'MY', IRN:'IR', ISR:'IL',
  ISL:'IS', SWE:'SE', DNK:'DK', NOR:'NO', FIN:'FI', POL:'PL',
  AUT:'AT', CHE:'CH', IRL:'IE', PER:'PE', CHL:'CL', BEL:'BE',
  HUN:'HU', CZE:'CZ', HRV:'HR', UKR:'UA', ROU:'RO', CUB:'CU',
  COL:'CO', URY:'UY', VEN:'VE', BOL:'BO', ECU:'EC', CAN:'CA',
  MAR:'MA', NGA:'NG', KEN:'KE', ETH:'ET', TZA:'TZ', GHA:'GH',
  TUN:'TN', DZA:'DZ', SAU:'SA', JOR:'JO', LBN:'LB', IRQ:'IQ',
  QAT:'QA', KWT:'KW', OMN:'OM', AFG:'AF', PAK:'PK', BGD:'BD',
  LKA:'LK', NPL:'NP', MMR:'MM', KHM:'KH', LAO:'LA', BRN:'BN',
};
const ISO2 = window.ISO2;
const COUNTRY_NAMES_FALLBACK = {
  JPN:'日本',CHN:'中国',KOR:'韩国',USA:'美国',GBR:'英国',FRA:'法国',ITA:'意大利',
  DEU:'德国',ESP:'西班牙',PRT:'葡萄牙',NLD:'荷兰',RUS:'俄罗斯',GRC:'希腊',
  MEX:'墨西哥',BRA:'巴西',ARG:'阿根廷',EGY:'埃及',ZAF:'南非',AUS:'澳大利亚',
  NZL:'新西兰',HKG:'香港',TWN:'台湾',THA:'泰国',SGP:'新加坡',IND:'印度',
  ARE:'阿联酋',TUR:'土耳其',
};
window.flagOf = window.flagOf || function(code3) {
  const c = ISO2[code3];
  if (!c) return '\u{1F3F3}';
  const base = 0x1F1E6 - 65;
  return String.fromCodePoint(base + c.charCodeAt(0)) + String.fromCodePoint(base + c.charCodeAt(1));
};
const flagOf = window.flagOf;

/* ================= passport (visited countries + ticket skins) ================= */

const TICKET_SKINS = [
  { id:'classic', name:'经典 · Classic',  name_en:'Classic',  need:0,  hint:'起步即解锁',     hint_en:'Unlocked from start' },
  { id:'vintage', name:'复古 · Vintage',  name_en:'Vintage',  need:3,  hint:'去 3 国解锁',    hint_en:'Visit 3 countries' },
  { id:'neon',    name:'霓虹 · Neon',     name_en:'Neon',     need:7,  hint:'去 7 国解锁',    hint_en:'Visit 7 countries' },
  { id:'sakura',  name:'樱花 · Sakura',   name_en:'Sakura',   need:12, hint:'去 12 国解锁',   hint_en:'Visit 12 countries' },
  { id:'gold',    name:'黄金 · Gold',     name_en:'Gold',     need:20, hint:'去 20 国解锁',   hint_en:'Visit 20 countries' },
];

function Passport({ tickets, skin, onSkin }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const L = lang === 'zh' ? {
    stamped:'已盖章', entries:'入境次数', skins:'车票配色',
    stamps:'已访问国家', notVisited:'未访', inUse:'使用中', tap:'点击使用',
  } : {
    stamped:'Stamped', entries:'Entries', skins:'Ticket Skins',
    stamps:'Countries', notVisited:'—', inUse:'In use', tap:'Tap to use',
  };
  const visits = {};
  for (const t of tickets) {
    if (t.toCountry) visits[t.toCountry] = (visits[t.toCountry] || 0) + 1;
  }
  const allCountries = new Set();
  if (window.COUNTRIES) Object.keys(window.COUNTRIES).forEach(c => allCountries.add(c));
  if (window.CITIES) window.CITIES.forEach(c => c.country && allCountries.add(c.country));
  const countryName = (code) => {
    const cc = window.COUNTRIES?.[code];
    if (!cc) return COUNTRY_NAMES_FALLBACK[code] || code;
    return lang === 'zh' ? (cc.name_zh || cc.name || code) : (cc.name || cc.name_zh || code);
  };
  const countryList = Array.from(allCountries).map(code => ({
    code,
    name: countryName(code),
    visits: visits[code] || 0,
  })).sort((a, b) => (b.visits - a.visits) || a.name.localeCompare(b.name));
  const visitedCount = countryList.filter(c => c.visits > 0).length;

  return (
    <div className="passport">
      <div className="pp-summary">
        <div className="pp-stat">
          <span className="pps-l">{L.stamped}</span>
          <span className="pps-v">{visitedCount} <span className="pps-dim">/ {allCountries.size}</span></span>
        </div>
        <div className="pp-stat">
          <span className="pps-l">{L.entries}</span>
          <span className="pps-v">{tickets.length}</span>
        </div>
      </div>

      <div className="pp-section-title">{L.skins}</div>
      <div className="pp-skins">
        {TICKET_SKINS.map(s => {
          const unlocked = visitedCount >= s.need;
          const active = skin === s.id;
          return (
            <button key={s.id}
                    type="button"
                    disabled={!unlocked}
                    className={`pp-skin sk-${s.id} ${unlocked ? 'unlocked' : 'locked'} ${active ? 'active' : ''}`}
                    onClick={() => unlocked && onSkin?.(s.id)}>
              <span className="pps-skin-strip" aria-hidden="true"></span>
              <span className="pps-skin-name">{lang === 'zh' ? s.name : (s.name_en || s.name)}</span>
              <span className="pps-skin-hint">{unlocked ? (active ? L.inUse : L.tap) : (lang === 'zh' ? s.hint : (s.hint_en || s.hint))}</span>
            </button>
          );
        })}
      </div>

      <div className="pp-section-title">{L.stamps}</div>
      <div className="pp-grid">
        {countryList.map(c => (
          <div key={c.code} className={`pp-stamp ${c.visits ? 'stamped' : 'locked'}`}>
            <div className="pps-flag">{flagOf(c.code)}</div>
            <div className="pps-name">{c.name}</div>
            <div className="pps-visits">{c.visits ? `× ${c.visits}` : L.notVisited}</div>
          </div>
        ))}
      </div>
    </div>
  );
}

function makePayslip(opts) {
  const { city, job, pet } = opts;
  const tax = Math.round(job.payUSD * 0.10);
  const net = job.payUSD - tax;
  return {
    id: `PS-${Date.now()}-${Math.random().toString(36).slice(2,6)}`,
    cityName: city.name_zh || city.name,
    cityId: city.id,
    countryId: city.country,
    jobId: job.id,
    jobTitle: job.title,
    jobMeta: job.meta,
    hours: job.hours,
    grossUSD: job.payUSD,
    taxUSD: tax,
    netUSD: net,
    earnedAt: new Date().toISOString(),
    payslipNo: `PS${Math.floor(Math.random()*900000+100000)}`,
    pet: pet ? { name: pet.name } : null,
  };
}

function Ticket({ t }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const date = t.departedAt ? new Date(t.departedAt).toISOString().slice(0,10) : '';
  const tlabels = { plane: 'BOARDING PASS', ship: 'FERRY TICKET', subway: 'RAIL TICKET', bus: 'BUS TICKET' };
  const total = (t.costUSD || 0) + (t.petFeeUSD || 0);
  // Localized city/transport/cabin names — fall back to whatever was stored on the ticket
  const cityFrom = (() => {
    const c = (window.CITIES || []).find(x => x.id === t.fromId);
    if (!c) return t.fromName;
    return lang === 'zh' ? (c.name_zh || c.name) : (c.name || c.name_zh);
  })();
  const cityTo = (() => {
    const c = (window.CITIES || []).find(x => x.id === t.toId);
    if (!c) return t.toName;
    return lang === 'zh' ? (c.name_zh || c.name) : (c.name || c.name_zh);
  })();
  const transportLabel = T(`trans.${t.transportId}`) || t.transportName;
  const cabinLabel = (() => {
    if (!t.cabinClassId) return t.cabinClassName || '';
    const cabin = (window.CABIN_CLASSES?.[t.transportId] || []).find(c => c.id === t.cabinClassId);
    if (!cabin) return t.cabinClassName || '';
    return lang === 'zh' ? (cabin.name_zh || cabin.name) : (cabin.name || cabin.name_zh);
  })();
  return (
    <div className={`ticket tk-${t.transportId}`}>
      <div className="tk-stub">
        <div className="tk-stub-meta">{tlabels[t.transportId] || 'TICKET'}</div>
        <div className="tk-stub-no">{t.flightNo}</div>
        <div className="tk-stub-bar"></div>
      </div>
      <div className="tk-main">
        <div className="tk-route">
          <div className="tk-side">
            <div className="tk-tag">FROM</div>
            <div className="tk-city">{cityFrom}</div>
          </div>
          <div className="tk-arrow">———————▸</div>
          <div className="tk-side">
            <div className="tk-tag">TO</div>
            <div className="tk-city">{cityTo}</div>
          </div>
        </div>
        <div className="tk-row">
          <div><span className="tk-l">{T('section.transport')}</span><span className="tk-v">{transportLabel}</span></div>
          {cabinLabel && <div><span className="tk-l">{T('depart.cabin')}</span><span className="tk-v">{cabinLabel}</span></div>}
          <div><span className="tk-l">SEAT</span><span className="tk-v">{t.seat}</span></div>
          <div><span className="tk-l">DATE</span><span className="tk-v">{date}</span></div>
        </div>
        {(t.costUSD || t.petFeeUSD) ? (
          <div className="tk-row tk-fare">
            <div><span className="tk-l">{T('depart.fare')}</span><span className="tk-v">${t.costUSD || 0}</span></div>
            {t.petFeeUSD > 0 && <div><span className="tk-l">{T('pet.title')}</span><span className="tk-v">+${t.petFeeUSD}</span></div>}
            <div><span className="tk-l">{T('depart.balance_after')}</span><span className="tk-v tk-total">${total}</span></div>
          </div>
        ) : null}
        {t.pet && (
          <div className="tk-pet">
            {t.pet.photo
              ? <img src={t.pet.photo} className="tk-pet-photo" alt=""/>
              : <span className="tk-pet-icon">P</span>}
            <span>{T('pet.title')} · {t.pet.name}</span>
          </div>
        )}
        {t.companions && t.companions.length > 0 && (
          <div className="tk-companions">
            <span className="tk-l">{T('top.companion')}</span>
            {t.companions.map(c => (
              <span key={c.id} className="tk-companion-chip">
                <span className="tcc-avatar">{(c.name || '?').charAt(0)}</span>
                {c.name}
              </span>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

function Payslip({ p }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const date = p.earnedAt ? new Date(p.earnedAt).toISOString().slice(0,10) : '';
  // Localize via lang-specific dict first (JOB_I18N.ko / .ja / etc.), then EN, then raw
  const loc = (p.jobId && window.localizeJob)
    ? window.localizeJob({ id: p.jobId, title: p.jobTitle, meta: p.jobMeta })
    : { title: p.jobTitle, meta: p.jobMeta };
  const title = loc?.title || p.jobTitle;
  const meta = loc?.meta || p.jobMeta;
  // Localize city name (look up by current CITIES)
  const city = (window.CITIES || []).find(c => c.name_zh === p.cityName || c.name === p.cityName);
  const cityName = city
    ? (lang === 'zh' ? (city.name_zh || city.name) : (city.name || city.name_zh))
    : p.cityName;
  return (
    <div className="payslip">
      <div className="ps-head">
        <div className="ps-tag">PAY SLIP</div>
        <div className="ps-no">{p.payslipNo}</div>
      </div>
      <div className="ps-title">{title}</div>
      <div className="ps-meta">{cityName} · {meta} · {p.hours}h · {date}</div>
      <div className="ps-rows">
        <div className="ps-row"><span>GROSS</span><span>${p.grossUSD}</span></div>
        <div className="ps-row deduct"><span>TAX (10%)</span><span>−${p.taxUSD}</span></div>
        <div className="ps-row total"><span>NET PAY</span><span>${p.netUSD}</span></div>
      </div>
      {p.pet && <div className="ps-pet">{T('pet.title')} · {p.pet.name}</div>}
    </div>
  );
}

function TravelLog({ tickets, payslips = [], diary = [], currentCityName, currentCountryId, skin = 'classic', onSkin, onAddDiary, onUpdateDiary, onDeleteDiary, onAddPhoto, onClose, onClear, onClearPayslips }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const TL = lang === 'zh' ? {
    log:'出行记录', payslipsAll:'工资单合集', stampsAll:'护照盖章合集', diaryAll:'旅行日记',
    generated:'生成日期',
    ticketsSub: (n, p, s) => `${n} 张车票 · 去过 ${p} 个地方 · 总支出 $${s}`,
    payslipsSub: (n, t) => `${n} 张薪资单 · 总实发 $${t}`,
    stampsSub: (vc, n) => `已盖章 ${vc} 国 · 总入境 ${n} 次`,
    diarySub: (n, p) => `${n} 篇日记 · 涵盖 ${p} 个地方`,
    emptyTickets:<>还没有出行记录。<br/>从地图选个目的地，按"出发"开始第一段旅程。</>,
    emptyPayslips:<>还没有薪资单。<br/>去悉尼 / 奥克兰 / 旧金山 / 洛杉矶 等地方打工攒下次旅费。</>,
    bnTickets:'车票数', bnVisited:'去过', bnPlaces:' 个地方', bnSpent:'总票务支出',
    bnSlips:'薪资单', bnSlipsUnit:' 张', bnHours:'累计工时',
    bnGross:'基本总额', bnTax:'扣税', bnNet:'实发总额',
    pngFail:'生成图片失败：',
  } : {
    log:T('top.log'), payslipsAll:'Payslip collection', stampsAll:'Passport stamps', diaryAll:'Travel Diary',
    generated:'Generated',
    ticketsSub: (n, p, s) => `${n} tickets · ${p} places visited · $${s} spent`,
    payslipsSub: (n, t) => `${n} payslips · $${t} net`,
    stampsSub: (vc, n) => `${vc} countries stamped · ${n} entries`,
    diarySub: (n, p) => `${n} entries · ${p} places`,
    emptyTickets:<>No tickets yet.<br/>Pick a destination on the map and hit Depart.</>,
    emptyPayslips:<>No payslips yet.<br/>Try Sydney / Auckland / SF / LA for working holidays.</>,
    bnTickets:'Tickets', bnVisited:'Visited', bnPlaces:' places', bnSpent:'Total fare',
    bnSlips:'Payslips', bnSlipsUnit:' total', bnHours:'Total hours',
    bnGross:'Gross', bnTax:'Tax', bnNet:'Net',
    pngFail:'Image export failed: ',
  };
  const [tab, setTab] = useState('tickets');
  const visited = new Set();
  tickets.forEach(t => { visited.add(t.fromName); visited.add(t.toName); });
  const totalSpent = tickets.reduce((s, t) => s + (t.costUSD || 0) + (t.petFeeUSD || 0), 0);
  const totalEarned = payslips.reduce((s, p) => s + (p.netUSD || 0), 0);
  const printRef = useRef(null);
  const [saving, setSaving] = useState(false);
  const handleSaveImage = async () => {
    if (saving) return;
    if (!window.htmlToImage || !printRef.current) {
      (window.conyAlert || window.alert)('图片工具未加载，请稍后再试');
      return;
    }
    setSaving(true);
    document.body.classList.add('cony-image-capture');
    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
    try {
      const node = printRef.current;
      const dataUrl = await window.htmlToImage.toPng(node, {
        backgroundColor: '#fffaf2',
        pixelRatio: 2,
        cacheBust: true,
        skipFonts: false,
      });
      const link = document.createElement('a');
      const stamp = new Date().toISOString().slice(0,10);
      const tabName = tab === 'tickets' ? (lang === 'zh' ? '车票' : 'tickets')
                    : tab === 'payslips' ? (lang === 'zh' ? '薪资单' : 'payslips')
                    : tab === 'passport' ? (lang === 'zh' ? '护照盖章' : 'passport')
                    : (lang === 'zh' ? '日记' : 'diary');
      link.download = `cony-${tabName}-${stamp}.png`;
      link.href = dataUrl;
      link.click();
      // also drop a copy into the in-app photo album so it's findable later
      onAddPhoto?.({
        id: `pa-${Date.now()}-${Math.random().toString(36).slice(2,5)}`,
        type: 'export',
        cityName: TL.log,
        image: dataUrl,
        caption: `${tabName} · ${stamp}`,
        ts: Date.now(),
      });
    } catch (e) {
      (window.conyAlert || window.alert)(TL.pngFail + (e.message || e));
    } finally {
      document.body.classList.remove('cony-image-capture');
      setSaving(false);
    }
  };

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal travel-log no-print-shadow" onClick={e=>e.stopPropagation()}>
        <div className="modal-head no-print">
          <div className="modal-title">{T('top.log')}</div>
          <button className="modal-close" onClick={onClose} aria-label={T('btn.close')}>×</button>
        </div>
        <div className="modal-summary no-print">
          <div className="ms-row">
            <span>{T('app.tickets')} <strong>{tickets.length}</strong></span>
            <span>{T('passport.visited')} <strong>{visited.size}</strong></span>
            <span>{T('app.fare')} <strong>${totalSpent}</strong></span>
            <span>{T('section.jobs')} <strong>${totalEarned}</strong></span>
          </div>
          <div className="ms-tabs">
            <button className={`ms-tab ${tab === 'tickets' ? 'active' : ''}`}
                    onClick={() => setTab('tickets')}>{T('app.tickets')} ({tickets.length})</button>
            <button className={`ms-tab ${tab === 'payslips' ? 'active' : ''}`}
                    onClick={() => setTab('payslips')}>{T('section.jobs')} ({payslips.length})</button>
            <button className={`ms-tab ${tab === 'passport' ? 'active' : ''}`}
                    onClick={() => setTab('passport')}>{T('passport.title')}</button>
            <button className={`ms-tab ${tab === 'diary' ? 'active' : ''}`}
                    onClick={() => setTab('diary')}>{T('diary.title')} ({diary.length})</button>
          </div>
        </div>
        <div className="print-container" ref={printRef}>
          <div className="print-header">
            <div className="ph-title">
              CONY · {tab === 'tickets' ? TL.log
                    : tab === 'payslips' ? TL.payslipsAll
                    : tab === 'passport' ? TL.stampsAll
                    : tab === 'diary' ? TL.diaryAll
                    : 'CONY'}
            </div>
            <div className="ph-sub">
              {tab === 'tickets' && TL.ticketsSub(tickets.length, visited.size, totalSpent)}
              {tab === 'payslips' && TL.payslipsSub(payslips.length, totalEarned)}
              {tab === 'passport' && (() => {
                const vc = new Set();
                tickets.forEach(t => { if (t.toCountry) vc.add(t.toCountry); });
                return TL.stampsSub(vc.size, tickets.length);
              })()}
              {tab === 'diary' &&
                TL.diarySub(diary.length, new Set(diary.map(d => d.cityName).filter(Boolean)).size)}
            </div>
            <div className="ph-date">{TL.generated} · {new Date().toISOString().slice(0,10)}</div>
          </div>
          <div className="modal-body">
            {tab === 'tickets' && (
              <div className={`ticket-list ts-${skin}`}>
                {tickets.length === 0 && (
                  <div className="empty">{TL.emptyTickets}</div>
                )}
                {tickets.length > 0 && (
                  <div className="total-banner">
                    <div className="tb-cell">
                      <span className="tb-l">{TL.bnTickets}</span>
                      <span className="tb-v">{tickets.length}</span>
                    </div>
                    <div className="tb-cell">
                      <span className="tb-l">{TL.bnVisited}</span>
                      <span className="tb-v">{visited.size}<small>{TL.bnPlaces}</small></span>
                    </div>
                    <div className="tb-cell">
                      <span className="tb-l">{TL.bnSpent}</span>
                      <span className="tb-v">${totalSpent}</span>
                    </div>
                  </div>
                )}
                {tickets.map(t => <Ticket key={t.id} t={t}/>)}
              </div>
            )}
            {tab === 'payslips' && (
              <div className="payslip-list">
                {payslips.length === 0 && (
                  <div className="empty">{TL.emptyPayslips}</div>
                )}
                {payslips.length > 0 && (() => {
                  const totalGross = payslips.reduce((s, p) => s + (p.grossUSD || 0), 0);
                  const totalTax   = payslips.reduce((s, p) => s + (p.taxUSD   || 0), 0);
                  const totalNet   = payslips.reduce((s, p) => s + (p.netUSD   || 0), 0);
                  const totalHours = payslips.reduce((s, p) => s + (p.hours    || 0), 0);
                  return (
                    <div className="total-banner green">
                      <div className="tb-cell">
                        <span className="tb-l">{TL.bnSlips}</span>
                        <span className="tb-v">{payslips.length}<small>{TL.bnSlipsUnit}</small></span>
                      </div>
                      <div className="tb-cell">
                        <span className="tb-l">{TL.bnHours}</span>
                        <span className="tb-v">{totalHours}<small> h</small></span>
                      </div>
                      <div className="tb-cell">
                        <span className="tb-l">{TL.bnGross}</span>
                        <span className="tb-v">${totalGross}</span>
                      </div>
                      <div className="tb-cell">
                        <span className="tb-l">{TL.bnTax}</span>
                        <span className="tb-v">−${totalTax}</span>
                      </div>
                      <div className="tb-cell">
                        <span className="tb-l">{TL.bnNet}</span>
                        <span className="tb-v">${totalNet}</span>
                      </div>
                    </div>
                  );
                })()}
                {payslips.map(p => <Payslip key={p.id} p={p}/>)}
              </div>
            )}
            {tab === 'passport' && (
              <>
                <Passport tickets={tickets} skin={skin} onSkin={onSkin}/>
                <div className="ach-section">
                  <div className="pp-section-title">成就 · ACHIEVEMENTS</div>
                  <Achievements tickets={tickets} payslips={payslips} photos={[]} diary={diary}/>
                </div>
              </>
            )}
            {tab === 'diary' && (
              <DiaryWithFeed
                entries={diary}
                currentCityName={currentCityName}
                currentCountryId={currentCountryId}
                onAdd={onAddDiary}
                onUpdate={onUpdateDiary}
                onDelete={onDeleteDiary}
              />
            )}
          </div>
        </div>
        <div className="modal-actions no-print">
          <button className="btn ghost" onClick={handleSaveImage}
                  disabled={saving || (tab === 'tickets' ? tickets.length === 0 : tab === 'payslips' ? payslips.length === 0 : false)}>
            {saving ? T('common.loading') : `📥 PNG`}
          </button>
          {tab === 'tickets' && tickets.length > 0 && (
            <button className="btn ghost" onClick={onClear}>{T('btn.delete')} · {T('app.tickets')}</button>
          )}
          {tab === 'payslips' && payslips.length > 0 && (
            <button className="btn ghost" onClick={onClearPayslips}>{T('btn.delete')} · {T('section.jobs')}</button>
          )}
        </div>
      </div>
    </div>
  );
}

/* ================= pet panel ================= */
const PET_SPECIES = [
  { id:'dog', name:'狗', name_en:'Dog' },
  { id:'cat', name:'猫', name_en:'Cat' },
  { id:'rabbit', name:'兔', name_en:'Rabbit' },
  { id:'bird', name:'鸟', name_en:'Bird' },
  { id:'fish', name:'鱼', name_en:'Fish' },
  { id:'other', name:'其他', name_en:'Other' },
];
const PET_GENDERS = [
  { id:'male', name:'公', name_en:'Male' },
  { id:'female', name:'母', name_en:'Female' },
  { id:'unknown', name:'未知', name_en:'?' },
];

function PetPanel({ pet, onSave, onClose }) {
  const T = (window.useT ? window.useT() : (k) => k);
  const lang = window.getLang ? window.getLang() : 'en';
  const P = lang === 'zh' ? {
    edit:'编辑旅伴', add:'添加旅伴',
    photoMissing:'未上传照片', photoSwap:'换张照片', photoUpload:'上传照片',
    fName:'名字', fSpecies:'物种', fGender:'性别', fAge:'年龄',
    phName:'给它起个名字', phAge:'3 岁 / 6 个月',
    drop:'不带宠物', save:'保存', join:'加入旅程',
    needName:'给宠物起个名字', tooBig:'照片请小于 1.5MB',
  } : {
    edit:'Edit pet', add:'Add a pet',
    photoMissing:'No photo yet', photoSwap:'Change photo', photoUpload:'Upload photo',
    fName:'Name', fSpecies:'Species', fGender:'Gender', fAge:'Age',
    phName:'Give it a name', phAge:'3 yrs / 6 mo',
    drop:'No pet', save:'Save', join:'Join the trip',
    needName:'Please name your pet', tooBig:'Photo must be under 1.5 MB',
  };
  const labelOf = (entry) => lang === 'zh' ? entry.name : (entry.name_en || entry.name);
  const [name, setName] = useState(pet?.name || '');
  const [species, setSpecies] = useState(pet?.species || 'dog');
  const [gender, setGender] = useState(pet?.gender || 'unknown');
  const [age, setAge] = useState(pet?.age || '');
  const [photo, setPhoto] = useState(pet?.photo || '');

  const onFile = (e) => {
    const f = e.target.files?.[0];
    if (!f) return;
    if (f.size > 1.5 * 1024 * 1024) { (window.conyAlert || window.alert)(P.tooBig); return; }
    const reader = new FileReader();
    reader.onload = () => setPhoto(reader.result);
    reader.readAsDataURL(f);
  };
  const save = () => {
    if (!name.trim()) { (window.conyAlert || window.alert)(P.needName); return; }
    onSave({ name: name.trim(), species, gender, age: age.trim(), photo });
    onClose();
  };
  const remove = () => { onSave(null); onClose(); };

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal pet-panel" onClick={e=>e.stopPropagation()}>
        <div className="modal-head">
          <div className="modal-title">{pet ? P.edit : P.add}</div>
          <button className="modal-close" onClick={onClose} aria-label={T('btn.close')}>×</button>
        </div>
        <div className="modal-body">
          <div className="pet-photo-wrap">
            {photo
              ? <img src={photo} className="pet-photo-img" alt="pet"/>
              : <div className="pet-photo-placeholder">{P.photoMissing}</div>}
            <label className="btn ghost pet-photo-btn">
              {photo ? P.photoSwap : P.photoUpload}
              <input type="file" accept="image/*" onChange={onFile} hidden/>
            </label>
          </div>
          <label className="pet-field">
            <span className="pf-l">{P.fName}</span>
            <input value={name} onChange={e=>setName(e.target.value)} placeholder={P.phName} maxLength={20}/>
          </label>
          <div className="pet-field">
            <span className="pf-l">{P.fSpecies}</span>
            <div className="chip-row">
              {PET_SPECIES.map(s => (
                <button key={s.id} type="button" className={`chip ${species === s.id ? 'active' : ''}`}
                        onClick={() => setSpecies(s.id)}>{labelOf(s)}</button>
              ))}
            </div>
          </div>
          <div className="pet-field">
            <span className="pf-l">{P.fGender}</span>
            <div className="chip-row">
              {PET_GENDERS.map(g => (
                <button key={g.id} type="button" className={`chip ${gender === g.id ? 'active' : ''}`}
                        onClick={() => setGender(g.id)}>{labelOf(g)}</button>
              ))}
            </div>
          </div>
          <label className="pet-field">
            <span className="pf-l">{P.fAge}</span>
            <input value={age} onChange={e=>setAge(e.target.value)} placeholder={P.phAge} maxLength={12}/>
          </label>
        </div>
        <div className="modal-actions">
          {pet && <button className="btn ghost" onClick={remove}>{P.drop}</button>}
          <button className="btn primary" onClick={save}>{pet ? P.save : P.join}</button>
        </div>
      </div>
    </div>
  );
}

/* ================= ambient sound mixer (multi-track Korean-aesthetic immersion) ================= */
// Builds a single AudioContext with all tracks pre-wired; each track can be toggled
// independently. Tracks share a global convolution reverb for spacious atmosphere.
// Korean Buddhist bell (범종) uses inharmonic partials with long decay + pentatonic notes.
function buildAmbientMixer(masterVol) {
  const AudioCtx = window.AudioContext || window.webkitAudioContext;
  if (!AudioCtx) return null;
  const ctx = new AudioCtx();
  // browser autoplay policy — most modern browsers suspend the context until user input.
  // call resume() immediately, AND register a one-time gesture listener as fallback
  // (cross-session: user reopens tab mid-flight, ctx was created without recent user gesture).
  const tryResume = () => { if (ctx.state === 'suspended') ctx.resume().catch(()=>{}); };
  tryResume();
  const wake = () => {
    tryResume();
    document.removeEventListener('pointerdown', wake);
    document.removeEventListener('keydown', wake);
    document.removeEventListener('touchstart', wake);
  };
  document.addEventListener('pointerdown', wake, { once: true });
  document.addEventListener('keydown', wake, { once: true });
  document.addEventListener('touchstart', wake, { once: true });
  const master = ctx.createGain();
  master.gain.value = masterVol;
  master.connect(ctx.destination);

  // Global reverb impulse — long warm tail for hanok-temple feel
  const reverbBuf = (() => {
    const seconds = 3.2;
    const len = Math.floor(ctx.sampleRate * seconds);
    const buf = ctx.createBuffer(2, len, ctx.sampleRate);
    for (let ch = 0; ch < 2; ch++) {
      const data = buf.getChannelData(ch);
      for (let i = 0; i < len; i++) {
        const t = i / len;
        const decay = Math.pow(1 - t, 2.3);
        data[i] = (Math.random() * 2 - 1) * decay * 0.7;
      }
    }
    return buf;
  })();
  const reverb = ctx.createConvolver();
  reverb.buffer = reverbBuf;
  const reverbReturn = ctx.createGain();
  reverbReturn.gain.value = 0.42;
  reverb.connect(reverbReturn).connect(master);

  // Pink-noise buffer once, shared across tracks
  const noiseBuf = (() => {
    const len = 4 * ctx.sampleRate;
    const buf = ctx.createBuffer(1, len, ctx.sampleRate);
    const data = buf.getChannelData(0);
    let b0=0,b1=0,b2=0,b3=0,b4=0,b5=0,b6=0;
    for (let i=0; i<len; i++) {
      const w = Math.random()*2 - 1;
      b0 = 0.99886*b0 + w*0.0555179;
      b1 = 0.99332*b1 + w*0.0750759;
      b2 = 0.96900*b2 + w*0.1538520;
      b3 = 0.86650*b3 + w*0.3104856;
      b4 = 0.55000*b4 + w*0.5329522;
      b5 = -0.7616*b5 - w*0.0168980;
      data[i] = (b0+b1+b2+b3+b4+b5+b6+w*0.5362) * 0.11;
      b6 = w*0.115926;
    }
    return buf;
  })();
  const whiteBuf = (() => {
    const len = 4 * ctx.sampleRate;
    const buf = ctx.createBuffer(1, len, ctx.sampleRate);
    const data = buf.getChannelData(0);
    for (let i=0; i<len; i++) data[i] = Math.random()*2 - 1;
    return buf;
  })();
  const noiseSrc = (type='pink') => {
    const s = ctx.createBufferSource();
    s.buffer = type === 'white' ? whiteBuf : noiseBuf;
    s.loop = true;
    return s;
  };
  const flt = (kind, f, q=0.7) => {
    const n = ctx.createBiquadFilter(); n.type = kind; n.frequency.value = f; n.Q.value = q; return n;
  };

  const tracks = {};
  const makeTrack = (name, wetSend) => {
    const dry = ctx.createGain(); dry.gain.value = 0;
    const wet = ctx.createGain(); wet.gain.value = 0;
    dry.connect(master);
    wet.connect(reverb);
    tracks[name] = { dry, wet, wetSend, nodes: [], timers: [], vol: 0 };
    return { dry, wet };
  };

  // 1. Cabin — soft cabin hum, low-passed pink noise + sub drone
  {
    const { dry, wet } = makeTrack('cabin', 0.18);
    const n = noiseSrc('pink');
    n.connect(flt('lowpass', 360, 0.6)).connect(flt('highpass', 45)).connect(dry);
    n.start();
    const drone = ctx.createOscillator(); drone.type = 'sine'; drone.frequency.value = 58;
    const dg = ctx.createGain(); dg.gain.value = 0.06;
    drone.connect(dg).connect(dry);
    drone.connect(dg).connect(wet);
    drone.start();
    tracks.cabin.nodes.push(n, drone);
  }

  // 2. Rain — distant rumble + close hiss layered
  {
    const { dry, wet } = makeTrack('rain', 0.30);
    const rumble = noiseSrc('pink');
    const rumbleG = ctx.createGain(); rumbleG.gain.value = 0.35;
    rumble.connect(flt('lowpass', 380)).connect(rumbleG).connect(dry);
    rumbleG.connect(wet);
    rumble.start();
    const hiss = noiseSrc('white');
    const hissG = ctx.createGain(); hissG.gain.value = 0.7;
    hiss.connect(flt('highpass', 1100)).connect(flt('lowpass', 8200))
        .connect(flt('bandpass', 2600, 0.6)).connect(hissG).connect(dry);
    hissG.connect(wet);
    hiss.start();
    tracks.rain.nodes.push(rumble, hiss);
  }

  // 3. Wind — bandpass with slow LFO on filter + amplitude gust
  {
    const { dry, wet } = makeTrack('wind', 0.40);
    const n = noiseSrc('pink');
    const bp = flt('bandpass', 520, 0.55);
    const lfoFreq = ctx.createOscillator(); lfoFreq.type = 'sine'; lfoFreq.frequency.value = 0.07;
    const lfoFreqG = ctx.createGain(); lfoFreqG.gain.value = 240;
    lfoFreq.connect(lfoFreqG).connect(bp.frequency);
    const amp = ctx.createGain(); amp.gain.value = 0.55;
    const lfoAmp = ctx.createOscillator(); lfoAmp.type = 'sine'; lfoAmp.frequency.value = 0.11;
    const lfoAmpG = ctx.createGain(); lfoAmpG.gain.value = 0.32;
    lfoAmp.connect(lfoAmpG).connect(amp.gain);
    n.connect(bp).connect(amp).connect(dry);
    amp.connect(wet);
    n.start(); lfoFreq.start(); lfoAmp.start();
    tracks.wind.nodes.push(n, lfoFreq, lfoAmp);
  }

  // 4. Temple bell (범종) — Korean Buddhist bell with pentatonic scale + 평조 mode
  //    Inharmonic partials, long decay, mostly wet for cavernous feel
  {
    const { dry, wet } = makeTrack('temple', 0.85);
    // pyeongjo-inspired pentatonic anchor frequencies (low register, soothing)
    const SCALE = [110.0, 123.5, 146.8, 164.8, 196.0]; // A2, B2, D3, E3, G3
    const ringBell = () => {
      const fundamental = SCALE[Math.floor(Math.random() * SCALE.length)];
      const t0 = ctx.currentTime;
      const PARTIALS = [
        { freq: fundamental * 0.5,  amp: 0.30, decay: 8.0 }, // hum tone
        { freq: fundamental,        amp: 0.45, decay: 6.5 }, // strike fundamental
        { freq: fundamental * 2.01, amp: 0.22, decay: 4.0 }, // 2nd, slightly detuned for "wow"
        { freq: fundamental * 3.04, amp: 0.13, decay: 2.8 }, // 3rd
        { freq: fundamental * 4.20, amp: 0.07, decay: 1.6 }, // shimmer
      ];
      PARTIALS.forEach(p => {
        const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = p.freq;
        const g = ctx.createGain(); g.gain.value = 0;
        g.gain.linearRampToValueAtTime(p.amp, t0 + 0.008);
        g.gain.exponentialRampToValueAtTime(0.0005, t0 + p.decay);
        o.connect(g).connect(wet);  // bell rings into the reverb tail
        g.connect(dry);
        o.start(t0);
        o.stop(t0 + p.decay + 0.3);
      });
    };
    setTimeout(ringBell, 1800);
    tracks.temple.timers.push(setInterval(ringBell, 11000));
  }

  // 5. Ocean — slow swelling waves with dual LFO for irregularity
  {
    const { dry, wet } = makeTrack('ocean', 0.40);
    const n = noiseSrc('pink');
    const swell = ctx.createGain(); swell.gain.value = 0.5;
    const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 0.08;
    const lg = ctx.createGain(); lg.gain.value = 0.4;
    lfo.connect(lg).connect(swell.gain);
    const lfo2 = ctx.createOscillator(); lfo2.type = 'sine'; lfo2.frequency.value = 0.033;
    const lg2 = ctx.createGain(); lg2.gain.value = 0.14;
    lfo2.connect(lg2).connect(swell.gain);
    n.connect(flt('lowpass', 600)).connect(swell).connect(dry);
    swell.connect(wet);
    n.start(); lfo.start(); lfo2.start();
    tracks.ocean.nodes.push(n, lfo, lfo2);
  }

  // 6. Forest — leaves rustling + occasional melodic bird chirps
  {
    const { dry, wet } = makeTrack('forest', 0.35);
    const leaves = noiseSrc('pink');
    const leavesG = ctx.createGain(); leavesG.gain.value = 0.40;
    const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 0.09;
    const lg = ctx.createGain(); lg.gain.value = 0.20;
    lfo.connect(lg).connect(leavesG.gain);
    leaves.connect(flt('highpass', 850)).connect(flt('lowpass', 4800)).connect(leavesG).connect(dry);
    leavesG.connect(wet);
    leaves.start(); lfo.start();
    tracks.forest.nodes.push(leaves, lfo);
    const chirp = () => {
      if (Math.random() > 0.55) return; // sparse
      const t0 = ctx.currentTime;
      const o = ctx.createOscillator(); o.type = 'sine';
      const fBase = 2000 + Math.random() * 1400;
      const notes = 2 + Math.floor(Math.random() * 2);
      let t = t0;
      for (let i = 0; i < notes; i++) {
        const f = fBase + (Math.random() - 0.5) * 350;
        o.frequency.setValueAtTime(f, t);
        o.frequency.exponentialRampToValueAtTime(f * 1.08, t + 0.06);
        t += 0.13;
      }
      const g = ctx.createGain(); g.gain.value = 0;
      g.gain.linearRampToValueAtTime(0.12, t0 + 0.01);
      g.gain.exponentialRampToValueAtTime(0.0005, t0 + 0.7);
      o.connect(g).connect(wet);
      g.connect(dry);
      o.start(t0); o.stop(t0 + 0.75);
    };
    tracks.forest.timers.push(setInterval(chirp, 3600));
  }

  const setTrack = (name, on, vol) => {
    const t = tracks[name];
    if (!t) return;
    const v = on ? vol : 0;
    try {
      t.dry.gain.setTargetAtTime(v, ctx.currentTime, 0.5);
      t.wet.gain.setTargetAtTime(v * t.wetSend, ctx.currentTime, 0.5);
    } catch (_) {}
    t.vol = v;
  };

  return {
    setTrack,
    setMaster: (v) => { try { master.gain.setTargetAtTime(v, ctx.currentTime, 0.1); } catch (_) {} },
    stop: () => {
      Object.values(tracks).forEach(t => {
        t.nodes.forEach(n => { try { n.stop(); } catch (_) {} });
        t.timers.forEach(id => clearInterval(id));
      });
      try { ctx.close(); } catch (_) {}
    },
  };
}

const SOUND_TRACKS = ['cabin','rain','wind','temple','ocean','forest'];
const SOUND_EMOJI = { cabin:'✈', rain:'🌧', wind:'🍃', temple:'🛕', ocean:'🌊', forest:'🌲' };

function AmbientSoundPicker() {
  const T = (window.useT ? window.useT() : (k) => k);
  const [open, setOpen] = useState(false);
  const [enabled, setEnabled] = useState(() => {
    try { return JSON.parse(localStorage.getItem('cony.sound.tracks') || '{}'); } catch { return {}; }
  });
  const [vols, setVols] = useState(() => {
    try { return JSON.parse(localStorage.getItem('cony.sound.vols') || '{}'); } catch { return {}; }
  });
  const [master, setMaster] = useState(() => {
    const v = parseFloat(localStorage.getItem('cony.sound.master') || '0.6');
    return isFinite(v) ? v : 0.6;
  });
  const mixerRef = useRef(null);

  const ensure = () => {
    if (mixerRef.current) return mixerRef.current;
    mixerRef.current = buildAmbientMixer(master);
    if (mixerRef.current) {
      // restore previously enabled tracks
      SOUND_TRACKS.forEach(name => {
        mixerRef.current.setTrack(name, !!enabled[name], vols[name] ?? 0.55);
      });
    }
    return mixerRef.current;
  };

  // mount: if any track was previously enabled, spin up the mixer
  useEffect(() => {
    if (Object.values(enabled).some(v => v)) ensure();
    return () => {
      if (mixerRef.current) { mixerRef.current.stop(); mixerRef.current = null; }
    };
  // eslint-disable-next-line
  }, []);

  useEffect(() => {
    if (mixerRef.current) mixerRef.current.setMaster(master);
    localStorage.setItem('cony.sound.master', String(master));
  }, [master]);

  const toggleTrack = (name) => {
    const m = ensure();
    const newEnabled = { ...enabled, [name]: !enabled[name] };
    setEnabled(newEnabled);
    localStorage.setItem('cony.sound.tracks', JSON.stringify(newEnabled));
    if (m) m.setTrack(name, newEnabled[name], vols[name] ?? 0.55);
  };

  const setTrackVol = (name, v) => {
    const newVols = { ...vols, [name]: v };
    setVols(newVols);
    localStorage.setItem('cony.sound.vols', JSON.stringify(newVols));
    if (mixerRef.current && enabled[name]) mixerRef.current.setTrack(name, true, v);
  };

  const onToggleOpen = () => {
    if (!open) ensure();
    setOpen(!open);
  };

  const activeCount = Object.values(enabled).filter(Boolean).length;
  // Show the hint bubble whenever the panel is closed AND no track is playing —
  // i.e., the user could be missing the feature. Disappears once any track plays.
  const showHint = !open && activeCount === 0;

  return (
    <div className={`ambient-sound-mixer ${open ? 'open' : ''} ${activeCount === 0 && !open ? 'idle' : ''}`}>
      <button className="asm-toggle" type="button" onClick={onToggleOpen}>
        <span className="asm-ico">{activeCount > 0 ? '🎵' : '🎶'}</span>
        <span className="asm-lbl">{T('sound.title')}</span>
        {activeCount > 0
          ? <span className="asm-count">{activeCount}</span>
          : <span className="asm-dot"/>}
      </button>
      {showHint && (
        <button className="asm-hint" type="button" onClick={onToggleOpen}>
          <span>{T('sound.hint')}</span>
        </button>
      )}
      {open && (
        <div className="asm-panel">
          <div className="asm-sub">{T('sound.subtitle')}</div>
          <div className="asm-tracks">
            {SOUND_TRACKS.map(name => (
              <div key={name} className={`asm-track ${enabled[name] ? 'on' : ''}`}>
                <button className="asm-track-btn" type="button" onClick={() => toggleTrack(name)}>
                  <span className="asm-track-emoji">{SOUND_EMOJI[name]}</span>
                  <span className="asm-track-name">{T('sound.' + name)}</span>
                  <span className="asm-track-led"/>
                </button>
                {enabled[name] && (
                  <input
                    className="asm-track-vol"
                    type="range" min="0" max="1" step="0.05"
                    value={vols[name] ?? 0.55}
                    onChange={(e) => setTrackVol(name, parseFloat(e.target.value))}
                  />
                )}
              </div>
            ))}
          </div>
          <div className="asm-master-row">
            <span className="asm-master-label">{T('sound.master')}</span>
            <input
              type="range" min="0" max="1" step="0.05"
              value={master}
              onChange={(e) => setMaster(parseFloat(e.target.value))}
            />
          </div>
        </div>
      )}
    </div>
  );
}

Object.assign(window, {
  WorldBackdrop, TopBar, TransportBar,
  CityNode, RoutePath, VehicleOnRoute,
  TravelOverlay, CityPanel, Intro, HintBanner, CountryView,
  CityDeck, CityCard, DomesticTravelBar, ClassPicker,
  TravelLog, Ticket, Payslip, Passport, JobBoard,
  WeatherPill, CityTasks, EncounterModal, MessageBoard,
  CabinScene, DepartConfirm, RewardPopup, ArrivalPopup, ClaimFundsModal, UpgradeCabinModal,
  MediaDetailModal, VisaModal, GameOverModal, PaywallModal, CultureDiaryModal, BonusClaimModal, ConyDialog,
  PhotoAlbum, Diary,
  Achievements,
  CityMemoryModal, LanguagePicker, SAFETY_SCENARIOS,
  AuthModal, ProfileSetup, FriendsModal, FriendTravelModal, OnlineFriendsBar,
  AmbientSoundPicker,
  seasonalFor, ACHIEVEMENTS, computeStats,
  makeTicket, makePayslip,
});
