// Tabi — screen components
// Persimmon palette, Noto Sans JP / TC

// ── User LLM/ASR config (localStorage only, never stored server-side) ──
window.tabiLLMHeaders = function() {
  try {
    const cfg = JSON.parse(localStorage.getItem('TABI_LLM_CONFIG') || '{}');
    // Support new format (llm_mode / asr_mode) and old format (mode)
    const llmMode = cfg.llm_mode || (cfg.mode === 'custom' ? 'custom' : 'default');
    const asrMode = cfg.asr_mode || (cfg.asr_base_url ? 'custom' : 'default');
    const h = {};
    if (llmMode === 'custom' && cfg.llm_base_url) {
      h['X-LLM-Base-Url'] = cfg.llm_base_url;
      h['X-LLM-Api-Key']  = cfg.llm_api_key  || '';
      h['X-LLM-Model']    = cfg.llm_model    || '';
    }
    if (asrMode === 'custom' && cfg.asr_base_url) {
      h['X-ASR-Base-Url'] = cfg.asr_base_url;
      h['X-ASR-Api-Key']  = cfg.asr_api_key  || '';
      h['X-ASR-Model']    = cfg.asr_model    || '';
    }
    return h;
  } catch { return {}; }
};

const TABI_COLORS = {
  ink: '#1A1612',
  ink2: '#5A4F45',
  ink3: '#8B7E70',
  bg: '#FAF6F0',
  bg2: '#F1EAE0',
  card: '#FFFFFF',
  line: 'rgba(26,22,18,0.08)',
  line2: 'rgba(26,22,18,0.14)',
  red: '#C8553D', // 朱
  redDeep: '#A0412F',
  green: '#5B7553',
  blue: '#3B5A7A',
  warn: '#D9A441',
};

const tabiFont = `"Noto Sans JP","Noto Sans TC","Hiragino Sans",-apple-system,system-ui,sans-serif`;
const tabiSerif = `"Noto Serif JP","Shippori Mincho","Hiragino Mincho ProN",serif`;

// ─────────── shared bits ───────────
function TabiTopBar({ title, subtitle, onBack, accent, right }) {
  return (
    <div style={{
      position: 'sticky', top: 0, zIndex: 50,
      padding: 'calc(env(safe-area-inset-top, 0px) + 14px) 20px 14px', display: 'flex', alignItems: 'center',
      gap: 12, borderBottom: `1px solid ${TABI_COLORS.line}`,
      background: TABI_COLORS.bg,
    }}>
      {onBack && (
        <button onClick={onBack} style={{
          width: 36, height: 36, borderRadius: 18, border: 'none',
          background: 'rgba(26,22,18,0.06)', color: TABI_COLORS.ink,
          fontSize: 18, cursor: 'pointer', display: 'grid', placeItems: 'center',
        }}>‹</button>
      )}
      <div style={{ flex: 1, minWidth: 0 }}>
        {subtitle && (
          <div style={{
            fontSize: 11, fontWeight: 600, letterSpacing: '0.14em',
            textTransform: 'uppercase', color: accent || TABI_COLORS.red,
            marginBottom: 2,
          }}>{subtitle}</div>
        )}
        <div style={{
          fontFamily: tabiSerif, fontSize: 22, fontWeight: 600,
          color: TABI_COLORS.ink, lineHeight: 1.1, letterSpacing: '-0.01em',
        }}>{title}</div>
      </div>
      {right}
    </div>
  );
}

function TabiTabBar({ active, onChange }) {
  const tabs = [
    { id: 'home', label: 'ホーム', zh: '首頁', icon: HomeIcon },
    { id: 'menu', label: 'メニュー', zh: '菜單', icon: MenuIcon },
    { id: 'voice', label: '会話', zh: '對話', icon: VoiceIcon },
    { id: 'trips', label: '家計簿', zh: '記帳', icon: WalletIcon },
  ];
  return (
    <div style={{
      display: 'flex', borderTop: `1px solid ${TABI_COLORS.line}`,
      background: 'rgba(250,246,240,0.95)', backdropFilter: 'blur(20px)',
      padding: `8px 4px calc(env(safe-area-inset-bottom, 0px) + 8px)`,
    }}>
      {tabs.map(t => {
        const on = active === t.id;
        const Icon = t.icon;
        return (
          <button key={t.id} onClick={() => onChange(t.id)} style={{
            flex: 1, border: 'none', background: 'transparent', cursor: 'pointer',
            padding: '7px 2px', display: 'flex', flexDirection: 'column',
            alignItems: 'center', gap: 4,
            color: on ? TABI_COLORS.red : TABI_COLORS.ink3,
          }}>
            <Icon size={23} active={on} />
            <span style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.02em' }}>
              {t.zh}
            </span>
          </button>
        );
      })}
    </div>
  );
}

// ─────────── line icons ───────────
const stroke = (active) => ({
  fill: 'none', stroke: 'currentColor',
  strokeWidth: active ? 2 : 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
});
function HomeIcon({ size = 24, active }) {
  return (<svg width={size} height={size} viewBox="0 0 24 24" {...stroke(active)}>
    <path d="M3 11L12 4l9 7v9a1 1 0 0 1-1 1h-5v-6h-6v6H4a1 1 0 0 1-1-1z"/>
  </svg>);
}
function MenuIcon({ size = 24, active }) {
  return (<svg width={size} height={size} viewBox="0 0 24 24" {...stroke(active)}>
    <rect x="4" y="3" width="16" height="18" rx="2"/>
    <path d="M8 8h8M8 12h8M8 16h5"/>
  </svg>);
}
function VoiceIcon({ size = 24, active }) {
  return (<svg width={size} height={size} viewBox="0 0 24 24" {...stroke(active)}>
    <rect x="9" y="3" width="6" height="12" rx="3"/>
    <path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>
  </svg>);
}
function ReceiptIcon({ size = 24, active }) {
  return (<svg width={size} height={size} viewBox="0 0 24 24" {...stroke(active)}>
    <path d="M5 3v18l2-1.5L9 21l2-1.5L13 21l2-1.5L17 21l2-1.5V3z"/>
    <path d="M8 8h8M8 12h8M8 16h5"/>
  </svg>);
}
function TicketIcon({ size = 24, active }) {
  return (<svg width={size} height={size} viewBox="0 0 24 24" {...stroke(active)}>
    <path d="M4 8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 0 0 4v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 0 0-4z"/>
    <path d="M9 9h6M9 12h6M9 15h4"/>
  </svg>);
}

function LogIcon({ size = 24, active }) {
  return (<svg width={size} height={size} viewBox="0 0 24 24" {...stroke(active)}>
    <circle cx="12" cy="12" r="9"/>
    <path d="M12 7v5l3 2"/>
  </svg>);
}
function WalletIcon({ size = 24, active }) {
  return (<svg width={size} height={size} viewBox="0 0 24 24" {...stroke(active)}>
    <path d="M3 7a2 2 0 0 1 2-2h13a1 1 0 0 1 1 1v2"/>
    <path d="M3 7v10a2 2 0 0 0 2 2h14a1 1 0 0 0 1-1v-3"/>
    <path d="M21 11h-4a2 2 0 0 0 0 4h4z"/>
  </svg>);
}
function CameraIcon({ size = 24, active }) {
  return (<svg width={size} height={size} viewBox="0 0 24 24" {...stroke(active)}>
    <path d="M4 8h3l1.5-2h7L17 8h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z"/>
    <circle cx="12" cy="13" r="4"/>
  </svg>);
}
function MicIcon({ size = 24, active }) {
  return (<svg width={size} height={size} viewBox="0 0 24 24" {...stroke(active)}>
    <rect x="9" y="3" width="6" height="12" rx="3"/>
    <path d="M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6"/>
  </svg>);
}
function PlayIcon({ size = 16 }) {
  return (<svg width={size} height={size} viewBox="0 0 16 16">
    <path d="M4 3l9 5-9 5z" fill="currentColor"/>
  </svg>);
}
function StopIcon({ size = 16 }) {
  return (<svg width={size} height={size} viewBox="0 0 16 16">
    <rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor"/>
  </svg>);
}
function CheckIcon({ size = 14 }) {
  return (<svg width={size} height={size} viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
    <path d="M3 7.5l3 3 5-6"/>
  </svg>);
}

// ─────────── TRAVEL PHRASES ─────────────────────────────────
let _ttsAudio = null;
let _ttsRunId = 0;
let _ttsEndHandler = null;

function _stopSpeech() {
  _ttsRunId += 1;
  if (_ttsAudio) {
    try { _ttsAudio.pause(); _ttsAudio.removeAttribute('src'); _ttsAudio.load(); } catch {}
    _ttsAudio = null;
  }
  window.speechSynthesis?.cancel();
  if (_ttsEndHandler) {
    const done = _ttsEndHandler;
    _ttsEndHandler = null;
    done();
  }
}

function _ttsLanguageKey(lang) {
  const value = String(lang || '').toLowerCase();
  if (value.startsWith('ja') || value.includes('_ja') || value.includes('-ja')) return 'ja';
  if (value.startsWith('zh') || value.includes('_zh') || value.includes('-zh')) return 'zh';
  if (value.startsWith('en') || value.includes('_en') || value.includes('-en')) return 'en';
  return null;
}

function _ttsConfig() {
  try { return JSON.parse(localStorage.getItem('TABI_LLM_CONFIG') || '{}'); }
  catch { return {}; }
}

function _ttsVoiceForLang(lang) {
  const key = _ttsLanguageKey(lang);
  if (!key) return '';
  return (_ttsConfig()[`tts_voice_${key}`] || '').trim();
}

function _saveTtsConfigPatch(patch) {
  const cfg = { ..._ttsConfig(), ...patch };
  localStorage.setItem('TABI_LLM_CONFIG', JSON.stringify(cfg));
  window.dispatchEvent(new CustomEvent('tabiTtsSettingsChanged', { detail: cfg }));
  return cfg;
}

function _ttsModelLangName(model) {
  const value = String(model || '').toLowerCase().trim();
  if (value.endsWith('-ja') || value.endsWith('_ja')) return '日文';
  if (value.endsWith('-zh') || value.endsWith('_zh')) return '中文';
  if (value.endsWith('-en') || value.endsWith('_en')) return '英文';
  return '';
}

function _ttsVoiceDisplayName(model, labels = {}) {
  const fallback = String(model || '').trim();
  const label = labels?.[model] || fallback;
  const lang = _ttsModelLangName(model);
  return lang ? `${label}（${lang}）` : label;
}


let _ttsModelsCache = null;
let _ttsModelsPromise = null;
async function _loadTtsModelOptions() {
  if (_ttsModelsCache) return _ttsModelsCache;
  if (!_ttsModelsPromise) {
    _ttsModelsPromise = fetch('/api/tts/models', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
      .then(async res => {
        const data = await res.json().catch(() => ({}));
        if (!res.ok) throw new Error(data.detail || '取得聲音失敗');
        _ttsModelsCache = {
          models: Array.isArray(data.models) ? data.models : [],
          labels: data.labels && typeof data.labels === 'object' ? data.labels : {},
        };
        return _ttsModelsCache;
      })
      .finally(() => { _ttsModelsPromise = null; });
  }
  return _ttsModelsPromise;
}

function _useTtsOptions() {
  const [state, setState] = React.useState(_ttsModelsCache || { models: [], labels: {} });
  React.useEffect(() => {
    let alive = true;
    _loadTtsModelOptions().then(data => { if (alive) setState(data); }).catch(() => {});
    return () => { alive = false; };
  }, []);
  return state;
}

function _numInRange(value, fallback, min, max) {
  const n = Number(value);
  if (!Number.isFinite(n)) return fallback;
  return Math.min(max, Math.max(min, n));
}

function _ttsPlaybackSettings() {
  const cfg = _ttsConfig();
  return {
    rate: _numInRange(cfg.tts_rate, 1, 0.5, 1.5),
    volume: _numInRange(cfg.tts_volume, 1, 0, 2.5),
  };
}

function _browserSpeech(text, lang, options = {}) {
  const finish = () => { _ttsEndHandler = null; options.onEnd?.(); };
  if (!text || !window.speechSynthesis) { finish(); return; }
  const playback = _ttsPlaybackSettings();
  const utt = new SpeechSynthesisUtterance(text);
  utt.lang = lang || 'ja-JP';
  utt.rate = _numInRange((options.rate || 0.9) * playback.rate, 0.9, 0.1, 10);
  utt.volume = Math.min(1, playback.volume);
  utt.onstart = options.onStart;
  utt.onend = finish;
  utt.onerror = () => { _ttsEndHandler = null; (options.onError || options.onEnd)?.(); };
  window.speechSynthesis.speak(utt);
}

function _splitSpeechText(text, lang) {
  const value = String(text || '').replace(/\s+/g, ' ').trim();
  if (!value) return [];
  const key = _ttsLanguageKey(lang) || 'ja';
  const limit = key === 'ja' ? 45 : key === 'zh' ? 55 : key === 'en' ? 120 : 70;
  if (value.length <= limit) return [value];

  const pattern = key === 'en'
    ? /[^.!?;]+[.!?;]?/g
    : /[^。！？!?；;、，,.]+[。！？!?；;、，,.]?/g;
  const pieces = value.match(pattern) || [value];
  const chunks = [];
  let current = '';

  const pushCurrent = () => {
    const chunk = current.trim();
    if (!chunk) return;
    if (chunks.length && chunk.length <= 6) {
      chunks[chunks.length - 1] += chunk;
    } else {
      chunks.push(chunk);
    }
    current = '';
  };

  for (const raw of pieces) {
    let piece = raw.trim();
    if (!piece) continue;
    while (piece.length > limit) {
      const head = piece.slice(0, limit);
      if (current) pushCurrent();
      chunks.push(head);
      piece = piece.slice(limit);
    }
    if (!piece) continue;
    if (!current) {
      current = piece;
    } else if ((current + piece).length <= limit) {
      current += piece;
    } else {
      pushCurrent();
      current = piece;
    }
  }
  pushCurrent();
  return chunks.filter(Boolean);
}

async function _createTtsSession(text, voice, lang) {
  const res = await fetch('/api/tts/session', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` },
    body: JSON.stringify({ text, voice }),
  });
  if (!res.ok) throw new Error(`TTS session ${res.status}`);
  return res.json();
}

async function _createTtsAudio(text, voice, lang) {
  const data = await _createTtsSession(text, voice, lang);
  return new Audio(data.stream_url);
}

async function _createTtsBlobAudio(text, voice, lang) {
  const data = await _createTtsSession(text, voice, lang);
  const res = await fetch(data.stream_url);
  if (!res.ok) throw new Error(`TTS blob ${res.status}`);
  const blob = await res.blob();
  const url = URL.createObjectURL(blob);
  const audio = new Audio(url);
  audio.addEventListener('ended', () => URL.revokeObjectURL(url), { once: true });
  audio.addEventListener('error', () => URL.revokeObjectURL(url), { once: true });
  return audio;
}

function _playAudioElement(audio, runId, onAudioStart) {
  return new Promise((resolve, reject) => {
    if (_ttsRunId !== runId) { resolve(false); return; }
    const playback = _ttsPlaybackSettings();
    audio.volume = Math.min(1, playback.volume);
    audio.playbackRate = playback.rate;
    audio.preservesPitch = true;
    audio.mozPreservesPitch = true;
    audio.webkitPreservesPitch = true;
    if (playback.volume > 1) {
      try {
        const AudioCtx = window.AudioContext || window.webkitAudioContext;
        if (AudioCtx && !audio._tabiGainNode) {
          const ctx = window._tabiAudioContext || new AudioCtx();
          window._tabiAudioContext = ctx;
          const source = ctx.createMediaElementSource(audio);
          const gain = ctx.createGain();
          gain.gain.value = playback.volume;
          source.connect(gain).connect(ctx.destination);
          audio._tabiGainNode = gain;
          if (ctx.state === 'suspended') ctx.resume().catch(() => {});
        } else if (audio._tabiGainNode) {
          audio._tabiGainNode.gain.value = playback.volume;
        }
      } catch {}
    }
    _ttsAudio = audio;
    audio.onended = () => resolve(true);
    audio.onerror = () => {
      const err = audio.error;
      const detail = err ? `audio playback failed code=${err.code} message=${err.message || ''}` : 'audio playback failed';
      reject(new Error(detail));
    };
    audio.play()
      .then(() => { if (_ttsRunId === runId) onAudioStart?.(); })
      .catch(err => reject(new Error(err?.message || err?.name || 'audio.play failed')));
  });
}

async function _playFishBlobSequence(chunks, voice, runId, options = {}) {
  for (let i = 0; i < chunks.length; i += 1) {
    if (_ttsRunId !== runId) return false;
    const audio = await _createTtsBlobAudio(chunks[i], voice, options.lang);
    await _playAudioElement(audio, runId, i === 0 ? options.onStart : null);
    if (_ttsRunId === runId) _ttsAudio = null;
  }
  if (_ttsRunId === runId) {
    _ttsEndHandler = null;
    options.onEnd?.();
  }
  return true;
}

async function _playSpeech(text, lang, options = {}) {
  if (!text) return false;
  const runId = _ttsRunId + 1;
  _stopSpeech();
  _ttsRunId = runId;
  const voice = _ttsVoiceForLang(lang);
  const fallback = (reason = 'unknown') => {
    if (_ttsRunId !== runId) return false;
    _ttsEndHandler = options.onEnd || null;
    _browserSpeech(text, lang, options);
    return true;
  };
  if (!voice) return fallback('no voice selected');
  if (!window.tabiToken?.()) return fallback('missing token');
  try {
    options.onGenerating?.();
    _ttsEndHandler = options.onEnd || null;
    const chunks = _splitSpeechText(text, lang);
    if (!chunks.length) return false;
    let nextAudioPromise = _createTtsAudio(chunks[0], voice, lang);
    for (let i = 0; i < chunks.length; i += 1) {
      if (_ttsRunId !== runId) return false;
      const audio = await nextAudioPromise;
      if (i + 1 < chunks.length) nextAudioPromise = _createTtsAudio(chunks[i + 1], voice, lang);
      await _playAudioElement(audio, runId, i === 0 ? options.onStart : null);
      if (_ttsRunId === runId) _ttsAudio = null;
    }
    if (_ttsRunId === runId) {
      _ttsEndHandler = null;
      options.onEnd?.();
    }
    return true;
  } catch (e) {
    try {
      const chunks = _splitSpeechText(text, lang);
      if (chunks.length && voice && window.tabiToken?.()) {
        return await _playFishBlobSequence(chunks, voice, runId, { ...options, lang });
      }
    } catch (blobError) {
      return fallback(`${e?.message || 'tts failed'}; blob retry failed: ${blobError?.message || blobError}`);
    }
    return fallback(e?.message || 'tts failed');
  }
}

function _speakJP(text, options = {}) {
  return _playSpeech(text, 'ja-JP', { rate: 0.85, ...options });
}


// ─────────── HOME ───────────
// ─────────── LLM SETTINGS SHEET ───────────

// Defined outside LLMSettingsSheet so React never remounts them mid-keystroke
const _settingsInp = {
  width: '100%', padding: '10px 12px', borderRadius: 10,
  border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg,
  fontSize: 16, color: TABI_COLORS.ink, outline: 'none',
  fontFamily: 'monospace', boxSizing: 'border-box',
};

function EyeIcon({ open }) {
  return open ? (
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
      <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
    </svg>
  ) : (
    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
      <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/>
    </svg>
  );
}

function ServiceSection({ type, label, tag, mode, setMode, url, setUrl, apiKey, setApiKey, model, setModel, urlPlaceholder, modelPlaceholder }) {
  const [showKey, setShowKey]     = React.useState(false);
  const [testState, setTestState] = React.useState('idle');
  const [testMsg, setTestMsg]     = React.useState('');

  const runTest = async () => {
    if (!url.trim()) { setTestState('error'); setTestMsg('請先填入 Base URL'); return; }
    if (type === 'llm' && !model.trim()) { setTestState('error'); setTestMsg('請先填入模型名稱'); return; }
    setTestState('testing'); setTestMsg('');
    try {
      const res = await fetch('/api/scan/test-endpoint', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${window.tabiToken()}`,
        },
        body: JSON.stringify({ type, base_url: url.trim(), api_key: apiKey, model: model.trim() }),
        signal: AbortSignal.timeout(20000),
      });
      const data = await res.json();
      if (data.ok) {
        setTestState('ok'); setTestMsg(data.detail || '連線成功');
      } else {
        setTestState('error'); setTestMsg(data.detail || '測試失敗');
      }
    } catch (e) {
      setTestState('error');
      setTestMsg(e.name === 'TimeoutError' ? '等待逾時（20s）' : '無法連線');
    }
  };

  const testColor = testState === 'ok' ? TABI_COLORS.green : testState === 'error' ? TABI_COLORS.red : TABI_COLORS.ink3;

  return (
    <div style={{ marginBottom: 6 }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
        <div>
          <div style={{ fontSize: 13, fontWeight: 700, color: TABI_COLORS.ink }}>{label}</div>
          <div style={{ fontSize: 10.5, color: TABI_COLORS.ink3, marginTop: 1 }}>{tag}</div>
        </div>
        <div style={{ display: 'flex', background: TABI_COLORS.bg2, borderRadius: 10, padding: 2, gap: 2 }}>
          {[['default', '預設'], ['custom', '自訂']].map(([v, lbl]) => (
            <button key={v} onClick={() => { setMode(v); setTestState('idle'); }} style={{
              padding: '6px 13px', borderRadius: 8, border: 'none', cursor: 'pointer',
              fontSize: 12, fontWeight: 600,
              background: mode === v ? TABI_COLORS.ink : 'transparent',
              color: mode === v ? '#fff' : TABI_COLORS.ink3,
              transition: 'all 0.15s',
            }}>{lbl}</button>
          ))}
        </div>
      </div>

      {mode === 'default' && (
        <div style={{ fontSize: 12, color: TABI_COLORS.ink3, padding: '8px 12px', background: TABI_COLORS.bg2, borderRadius: 10 }}>
          使用網站端點
        </div>
      )}

      {mode === 'custom' && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          <div>
            <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginBottom: 4, fontWeight: 600 }}>Base URL</div>
            <input value={url} onChange={e => { setUrl(e.target.value); setTestState('idle'); }} placeholder={urlPlaceholder} style={_settingsInp} />
          </div>
          <div>
            <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginBottom: 4, fontWeight: 600 }}>API Key</div>
            <div style={{ position: 'relative' }}>
              <input
                type={showKey ? 'text' : 'password'}
                value={apiKey} onChange={e => { setApiKey(e.target.value); setTestState('idle'); }}
                placeholder="sk-..."
                style={{ ..._settingsInp, paddingRight: 42 }}
              />
              <button onClick={() => setShowKey(v => !v)} style={{
                position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
                background: 'transparent', border: 'none', cursor: 'pointer',
                color: TABI_COLORS.ink3, display: 'grid', placeItems: 'center', padding: 2,
              }}>
                <EyeIcon open={showKey} />
              </button>
            </div>
          </div>
          <div>
            <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginBottom: 4, fontWeight: 600 }}>模型名稱</div>
            <input value={model} onChange={e => setModel(e.target.value)} placeholder={modelPlaceholder} style={_settingsInp} />
          </div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
            <button onClick={runTest} disabled={testState === 'testing'} style={{
              padding: '8px 16px', borderRadius: 10, border: `1px solid ${TABI_COLORS.line2}`,
              background: 'transparent', fontSize: 12, fontWeight: 600,
              color: testState === 'testing' ? TABI_COLORS.ink3 : TABI_COLORS.ink,
              cursor: testState === 'testing' ? 'default' : 'pointer', whiteSpace: 'nowrap',
            }}>
              {testState === 'testing' ? '測試中…' : '測試端點'}
            </button>
            {testState !== 'idle' && testState !== 'testing' && (
              <div style={{ fontSize: 11.5, color: testColor, lineHeight: 1.4 }}>
                {testState === 'ok' ? '✓ ' : '✗ '}{testMsg}
              </div>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

function TTSInlineSettings({ lang = 'ja-JP', dark = false }) {
  const key = _ttsLanguageKey(lang);
  const { models, labels } = _useTtsOptions();
  const [cfg, setCfg] = React.useState(_ttsConfig());

  React.useEffect(() => {
    const sync = () => setCfg(_ttsConfig());
    window.addEventListener('tabiTtsSettingsChanged', sync);
    return () => window.removeEventListener('tabiTtsSettingsChanged', sync);
  }, []);

  const voiceKey = key ? `tts_voice_${key}` : null;
  const voice = voiceKey ? (cfg[voiceKey] || '') : '';
  const volume = _numInRange(cfg.tts_volume, 1, 0, 2.5);
  const rate = _numInRange(cfg.tts_rate, 1, 0.5, 1.5);
  const recommendedOptions = key ? models.filter(name => String(name).toLowerCase().trim().endsWith(key)) : [];
  const otherOptions = key ? models.filter(name => !recommendedOptions.includes(name)) : [];
  const ink = dark ? 'rgba(255,255,255,0.88)' : TABI_COLORS.ink;
  const muted = dark ? 'rgba(255,255,255,0.5)' : TABI_COLORS.ink3;
  const panelBg = dark ? 'rgba(255,255,255,0.08)' : TABI_COLORS.bg2;
  const fieldBg = dark ? 'rgba(0,0,0,0.18)' : TABI_COLORS.card;
  const border = dark ? 'rgba(255,255,255,0.12)' : TABI_COLORS.line;
  const patch = next => setCfg(_saveTtsConfigPatch(next));

  const Slider = ({ label, value, min, max, step, suffix, format, onChange }) => (
    <div style={{ minWidth: 0 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
        <span style={{ fontSize: 10.5, color: muted, fontWeight: 700 }}>{label}</span>
        <span style={{ fontSize: 10.5, color: ink, fontWeight: 700 }}>{format(value)}{suffix}</span>
      </div>
      <input
        type="range"
        min={min}
        max={max}
        step={step}
        value={value}
        onChange={e => onChange(Number(e.target.value))}
        style={{ width: '100%', accentColor: TABI_COLORS.red }}
      />
    </div>
  );

  return (
    <details style={{ marginTop: 12, borderRadius: 12, background: panelBg, border: `1px solid ${border}`, overflow: 'hidden' }}>
      <summary style={{ cursor: 'pointer', listStyle: 'none', padding: '9px 11px', color: ink, fontSize: 12, fontWeight: 800 }}>
        語音設定
        <span style={{ marginLeft: 8, color: muted, fontWeight: 600 }}>
          {voice ? _ttsVoiceDisplayName(voice, labels) : '瀏覽器語音'} · {Math.round(volume * 100)}% · {rate.toFixed(2)}x
        </span>
      </summary>
      <div style={{ padding: '0 11px 11px', display: 'flex', flexDirection: 'column', gap: 8 }}>
        {key ? (
          <label style={{ display: 'block' }}>
            <div style={{ fontSize: 10.5, color: muted, fontWeight: 700, marginBottom: 4 }}>聲音</div>
            <select value={voice} onChange={e => patch({ [voiceKey]: e.target.value })} style={{
              width: '100%', padding: '8px 10px', borderRadius: 9, border: `1px solid ${border}`,
              background: fieldBg, color: ink, fontSize: 12, fontFamily: tabiFont, outline: 'none',
            }}>
              <option value="">使用瀏覽器內建語音</option>
              {recommendedOptions.length > 0 && (
                <optgroup label="推薦聲音">
                  {recommendedOptions.map(v => <option key={v} value={v}>{_ttsVoiceDisplayName(v, labels)}</option>)}
                </optgroup>
              )}
              {otherOptions.length > 0 && (
                <optgroup label="其他語系聲音，效果可能不穩">
                  {otherOptions.map(v => <option key={v} value={v}>{_ttsVoiceDisplayName(v, labels)}</option>)}
                </optgroup>
              )}
              {voice && !models.includes(voice) && <option value={voice}>{_ttsVoiceDisplayName(voice, labels)}</option>}
            </select>
          </label>
        ) : (
          <div style={{ fontSize: 11, color: muted, lineHeight: 1.5 }}>此語言目前使用瀏覽器內建語音。</div>
        )}
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
          <Slider label="音量" value={volume} min={0} max={2.5} step={0.05} suffix="%" format={v => Math.round(v * 100)} onChange={v => patch({ tts_volume: v })} />
          <Slider label="語速" value={rate} min={0.5} max={1.5} step={0.05} suffix="x" format={v => Number(v).toFixed(2)} onChange={v => patch({ tts_rate: v })} />
        </div>
      </div>
    </details>
  );
}

function TTSVoiceSection({ models, labels = {}, status, voiceJa, setVoiceJa, voiceZh, setVoiceZh, voiceEn, setVoiceEn, volume, setVolume, rate, setRate, onReload }) {
  const SettingSlider = ({ label, value, setValue, min, max, step, suffix, format }) => (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
        <div style={{ fontSize: 11, color: TABI_COLORS.ink3, fontWeight: 600 }}>{label}</div>
        <div style={{ fontSize: 11, color: TABI_COLORS.ink, fontWeight: 700 }}>{format(value)}{suffix}</div>
      </div>
      <input
        type="range"
        min={min}
        max={max}
        step={step}
        value={value}
        onChange={e => setValue(Number(e.target.value))}
        style={{ width: '100%', accentColor: TABI_COLORS.red }}
      />
    </div>
  );
  const voicesFor = (lang) => models.filter(name => {
    const n = String(name).toLowerCase().trim();
    return n.endsWith(lang);
  });
  const Field = ({ label, value, setValue, lang }) => {
    const recommendedOptions = voicesFor(lang);
    const otherOptions = models.filter(name => !recommendedOptions.includes(name));
    return (
      <div>
        <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginBottom: 4, fontWeight: 600 }}>{label}</div>
        <select value={value} onChange={e => setValue(e.target.value)} style={_settingsInp}>
          <option value="">使用瀏覽器內建語音</option>
          {recommendedOptions.length > 0 && (
            <optgroup label="推薦聲音">
              {recommendedOptions.map(v => <option key={v} value={v}>{_ttsVoiceDisplayName(v, labels)}</option>)}
            </optgroup>
          )}
          {otherOptions.length > 0 && (
            <optgroup label="其他語系聲音，效果可能不穩">
              {otherOptions.map(v => <option key={v} value={v}>{_ttsVoiceDisplayName(v, labels)}</option>)}
            </optgroup>
          )}
          {value && !models.includes(value) && <option value={value}>{_ttsVoiceDisplayName(value, labels)}</option>}
        </select>
      </div>
    );
  };
  return (
    <div style={{ marginBottom: 6 }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
        <div>
          <div style={{ fontSize: 13, fontWeight: 700, color: TABI_COLORS.ink }}>語音播放（TTS）</div>
          <div style={{ fontSize: 10.5, color: TABI_COLORS.ink3, marginTop: 1 }}>Fish Speech 聲音選擇</div>
        </div>
        <button onClick={onReload} style={{
          padding: '7px 12px', borderRadius: 10, border: `1px solid ${TABI_COLORS.line2}`,
          background: 'transparent', color: TABI_COLORS.ink, fontSize: 12, fontWeight: 600, cursor: 'pointer',
        }}>更新聲音</button>
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
        <Field label="日文聲音" value={voiceJa} setValue={setVoiceJa} lang="ja" />
        <Field label="中文聲音" value={voiceZh} setValue={setVoiceZh} lang="zh" />
        <Field label="英文聲音" value={voiceEn} setValue={setVoiceEn} lang="en" />
        <div style={{ height: 1, background: TABI_COLORS.line, margin: '4px 0' }} />
        <SettingSlider label="音量" value={volume} setValue={setVolume} min={0} max={2.5} step={0.05} suffix="%" format={v => Math.round(v * 100)} />
        <SettingSlider label="語速" value={rate} setValue={setRate} min={0.5} max={1.5} step={0.05} suffix="x" format={v => Number(v).toFixed(2)} />
      </div>
      <div style={{ fontSize: 11, color: status.ok ? TABI_COLORS.green : TABI_COLORS.ink3, marginTop: 8, lineHeight: 1.5 }}>
        {status.text}
      </div>
    </div>
  );
}

function LLMSettingsSheet({ onClose }) {
  const load = () => {
    try { return JSON.parse(localStorage.getItem('TABI_LLM_CONFIG') || '{}'); }
    catch { return {}; }
  };
  const saved = load();

  const initLlmMode = saved.llm_mode || (saved.mode === 'custom' ? 'custom' : 'default');
  const initAsrMode = saved.asr_mode || (saved.asr_base_url ? 'custom' : 'default');

  const [llmMode, setLlmMode]   = React.useState(initLlmMode);
  const [llmUrl, setLlmUrl]     = React.useState(saved.llm_base_url || '');
  const [llmKey, setLlmKey]     = React.useState(saved.llm_api_key  || '');
  const [llmModel, setLlmModel] = React.useState(saved.llm_model    || '');

  const [asrMode, setAsrMode]   = React.useState(initAsrMode);
  const [asrUrl, setAsrUrl]     = React.useState(saved.asr_base_url || '');
  const [asrKey, setAsrKey]     = React.useState(saved.asr_api_key  || '');
  const [asrModel, setAsrModel] = React.useState(saved.asr_model    || '');

  const [ttsModels, setTtsModels] = React.useState([]);
  const [ttsLabels, setTtsLabels] = React.useState({});
  const [ttsStatus, setTtsStatus] = React.useState({ ok: false, text: '尚未載入聲音；未選擇時會使用瀏覽器內建語音。' });
  const [ttsVoiceJa, setTtsVoiceJa] = React.useState(saved.tts_voice_ja || '');
  const [ttsVoiceZh, setTtsVoiceZh] = React.useState(saved.tts_voice_zh || '');
  const [ttsVoiceEn, setTtsVoiceEn] = React.useState(saved.tts_voice_en || '');
  const [ttsVolume, setTtsVolume] = React.useState(_numInRange(saved.tts_volume, 1, 0, 2.5));
  const [ttsRate, setTtsRate] = React.useState(_numInRange(saved.tts_rate, 1, 0.5, 1.5));

  const [savedOk, setSavedOk]   = React.useState(false);

  const loadTtsModels = async () => {
    try {
      setTtsStatus({ ok: false, text: '正在取得聲音列表…' });
      const res = await fetch('/api/tts/models', { headers: { Authorization: `Bearer ${window.tabiToken()}` } });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.detail || '取得失敗');
      const models = Array.isArray(data.models) ? data.models : [];
      setTtsModels(models);
      setTtsLabels(data.labels && typeof data.labels === 'object' ? data.labels : {});
      setTtsStatus({ ok: true, text: models.length ? `已載入 ${models.length} 個聲音` : '端點可用，但目前沒有聲音。' });
    } catch (e) {
      setTtsModels([]);
      setTtsLabels({});
      setTtsStatus({ ok: false, text: `TTS 未啟用或無法連線，會使用瀏覽器內建語音。${e.message ? ' ' + e.message : ''}` });
    }
  };

  React.useEffect(() => { loadTtsModels(); }, []);

  const save = () => {
    const cfg = {
      llm_mode:     llmMode,
      llm_base_url: llmUrl.trim(),
      llm_api_key:  llmKey,
      llm_model:    llmModel.trim(),
      asr_mode:     asrMode,
      asr_base_url: asrUrl.trim(),
      asr_api_key:  asrKey,
      asr_model:    asrModel.trim(),
      tts_voice_ja: ttsVoiceJa,
      tts_voice_zh: ttsVoiceZh,
      tts_voice_en: ttsVoiceEn,
      tts_volume: ttsVolume,
      tts_rate: ttsRate,
    };
    localStorage.setItem('TABI_LLM_CONFIG', JSON.stringify(cfg));
    window.dispatchEvent(new CustomEvent('tabiTtsSettingsChanged', { detail: cfg }));
    setSavedOk(true);
    setTimeout(() => { setSavedOk(false); onClose(); }, 800);
  };

  return ReactDOM.createPortal(
    <div style={{ position: 'fixed', inset: 0, zIndex: 9200, display: 'flex', alignItems: 'flex-end' }}>
      <div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.45)' }} />
      <div style={{
        position: 'relative', width: '100%', background: TABI_COLORS.bg,
        borderRadius: '20px 20px 0 0', padding: '12px 0 36px', maxHeight: '88vh', overflowY: 'auto',
      }}>
        <div style={{ width: 36, height: 4, borderRadius: 2, background: TABI_COLORS.line2, margin: '6px auto 16px' }} />

        {/* Header */}
        <div style={{ padding: '0 20px 16px', borderBottom: `1px solid ${TABI_COLORS.line}`, display: 'flex', alignItems: 'center' }}>
          <div style={{ flex: 1 }}>
            <div style={{ fontFamily: tabiSerif, fontSize: 18, fontWeight: 700, color: TABI_COLORS.ink }}>AI 端點設定</div>
            <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginTop: 2 }}>設定值僅存於本機，不會上傳</div>
          </div>
          <button onClick={onClose} style={{ background: 'transparent', border: 'none', cursor: 'pointer', fontSize: 22, color: TABI_COLORS.ink3, lineHeight: 1 }}>×</button>
        </div>

        <div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 16 }}>

          {/* LLM section */}
          <ServiceSection
            type="llm"
            label="翻譯模型（LLM）" tag="菜單翻譯・拍照翻譯・語音翻譯"
            mode={llmMode} setMode={setLlmMode}
            url={llmUrl} setUrl={setLlmUrl}
            apiKey={llmKey} setApiKey={setLlmKey}
            model={llmModel} setModel={setLlmModel}
            urlPlaceholder="https://api.openai.com/v1"
            modelPlaceholder="gpt-4o-mini"
          />

          <div style={{ height: 1, background: TABI_COLORS.line }} />

          {/* ASR section */}
          <ServiceSection
            type="asr"
            label="語音辨識（ASR）" tag="即時對話・語音輸入"
            mode={asrMode} setMode={setAsrMode}
            url={asrUrl} setUrl={setAsrUrl}
            apiKey={asrKey} setApiKey={setAsrKey}
            model={asrModel} setModel={setAsrModel}
            urlPlaceholder="https://api.openai.com/v1"
            modelPlaceholder="whisper-1"
          />

          <div style={{ height: 1, background: TABI_COLORS.line }} />

          <TTSVoiceSection
            models={ttsModels}
            labels={ttsLabels}
            status={ttsStatus}
            voiceJa={ttsVoiceJa} setVoiceJa={setTtsVoiceJa}
            voiceZh={ttsVoiceZh} setVoiceZh={setTtsVoiceZh}
            voiceEn={ttsVoiceEn} setVoiceEn={setTtsVoiceEn}
            volume={ttsVolume} setVolume={setTtsVolume}
            rate={ttsRate} setRate={setTtsRate}
            onReload={loadTtsModels}
          />

          <div style={{ fontSize: 11, color: TABI_COLORS.ink3, lineHeight: 1.6, padding: '10px 12px', background: TABI_COLORS.bg2, borderRadius: 10 }}>
            🔒 API Key 只存於你的手機，不會傳送至本網站資料庫。
          </div>

          <button onClick={save} style={{
            width: '100%', padding: '14px', borderRadius: 14, border: 'none',
            background: savedOk ? TABI_COLORS.green : TABI_COLORS.ink,
            color: '#fff', fontSize: 15, fontWeight: 700, cursor: 'pointer', transition: 'background 0.2s',
          }}>{savedOk ? '✓ 已儲存' : '儲存設定'}</button>
        </div>
      </div>
    </div>,
    document.body
  );
}

function HomeScreen({ go, userName }) {
  const [camSheet, setCamSheet] = React.useState(false);
  const [scanReceiptOpen, setScanReceiptOpen] = React.useState(false);
  const [showLLMSettings, setShowLLMSettings] = React.useState(false);
  const [homeTrips, setHomeTrips] = React.useState([]);
  const [allPhrases, setAllPhrases] = React.useState([]);
  const [sampledPhrases, setSampledPhrases] = React.useState([]);
  const [speaking, setSpeaking] = React.useState(null);
  const [ttsPending, setTtsPending] = React.useState(null);

  function loadHomeTrips() {
    fetch('/api/trips', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
      .then(r => r.ok ? r.json() : [])
      .then(d => setHomeTrips(Array.isArray(d) ? d : []))
      .catch(() => {});
  }

  React.useEffect(() => {
    loadHomeTrips();
    fetch('/api/phrases', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
      .then(r => r.ok ? r.json() : [])
      .then(data => {
        if (!Array.isArray(data) || !data.length) return;
        setAllPhrases(data);
        const shuffled = [...data].sort(() => Math.random() - 0.5);
        setSampledPhrases(shuffled.slice(0, 5));
      })
      .catch(() => {});
  }, []);

  function handleSpeak(idx, text) {
    if (speaking === idx || ttsPending === idx) {
      _stopSpeech();
      setSpeaking(null);
      setTtsPending(null);
      return;
    }
    _speakJP(text, {
      onGenerating: () => { setTtsPending(idx); setSpeaking(null); },
      onStart: () => { setTtsPending(null); setSpeaking(idx); },
      onEnd: () => { setTtsPending(null); setSpeaking(s => s === idx ? null : s); },
      onError: () => { setTtsPending(null); setSpeaking(s => s === idx ? null : s); },
    });
  }

  const features = [
    { id: 'sign',        jp: '写真翻訳',     zh: '拍照翻譯',   desc: '路標・看板・標示翻譯', tone: TABI_COLORS.warn,  Icon: CameraIcon },
    { id: 'menuHistory', jp: 'メニュー翻訳', zh: '菜單翻譯',   desc: '拍照辨識・自動點餐句', tone: TABI_COLORS.red,   Icon: MenuIcon },
    { id: 'voice',       jp: '会話通訳',     zh: '即時對話',   desc: '說話即翻、雙向對話',   tone: TABI_COLORS.blue,  Icon: MicIcon },
    { id: 'receipt',     jp: 'レシート整理', zh: '掃描發票',   desc: '拍發票・自動辨識・匯入記帳', tone: TABI_COLORS.green, Icon: ReceiptIcon },
    { id: 'trips',       jp: '旅の家計簿',   zh: '共同記帳',   desc: '多人拆帳・自動結算',   tone: TABI_COLORS.warn,  Icon: ReceiptIcon },
    { id: 'coupons',     jp: 'クーポン',     zh: '優惠券',     desc: '折扣優惠・分類搜尋',   tone: TABI_COLORS.red,   Icon: TicketIcon },
  ];
  return (
    <div style={{ background: TABI_COLORS.bg, minHeight: '100%', paddingBottom: 24 }}>

      {/* Camera mode choice sheet */}
      {camSheet && (
        <div onClick={() => setCamSheet(false)} style={{
          position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.4)',
          zIndex: 100, display: 'flex', alignItems: 'flex-end',
        }}>
          <div onClick={e => e.stopPropagation()} style={{
            width: '100%', background: TABI_COLORS.bg, borderRadius: '20px 20px 0 0',
            padding: '12px 20px 36px',
          }}>
            <div style={{ width: 36, height: 4, borderRadius: 2, background: TABI_COLORS.line2, margin: '6px auto 18px' }}/>
            <div style={{ fontFamily: tabiSerif, fontSize: 18, fontWeight: 600, color: TABI_COLORS.ink, marginBottom: 14 }}>
              選擇拍照模式
            </div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
              {[
                { id: 'camera', title: '菜單翻譯', desc: '掃描菜單・選料理・生成點餐句', Icon: MenuIcon, tone: TABI_COLORS.red },
                { id: 'sign',   title: '拍照翻譯', desc: '路標・看板・標示等任意文字翻譯', Icon: CameraIcon, tone: TABI_COLORS.warn },
              ].map(opt => (
                <button key={opt.id} onClick={() => { setCamSheet(false); go(opt.id); }} style={{
                  width: '100%', border: `1px solid ${TABI_COLORS.line}`, cursor: 'pointer', textAlign: 'left',
                  background: TABI_COLORS.card, borderRadius: 16, padding: '16px 14px',
                  display: 'flex', alignItems: 'center', gap: 14,
                }}>
                  <div style={{
                    width: 46, height: 46, borderRadius: 14, flexShrink: 0,
                    background: `${opt.tone}18`, color: opt.tone,
                    display: 'grid', placeItems: 'center',
                  }}>
                    <opt.Icon size={22} active />
                  </div>
                  <div style={{ flex: 1 }}>
                    <div style={{ fontFamily: tabiSerif, fontSize: 16, fontWeight: 600, color: TABI_COLORS.ink }}>{opt.title}</div>
                    <div style={{ fontSize: 12, color: TABI_COLORS.ink3, marginTop: 3, lineHeight: 1.4 }}>{opt.desc}</div>
                  </div>
                  <div style={{ fontSize: 20, color: TABI_COLORS.ink3, flexShrink: 0 }}>›</div>
                </button>
              ))}
            </div>
          </div>
        </div>
      )}

      {/* hero */}
      <div style={{
        padding: 'calc(env(safe-area-inset-top, 0px) + 20px) 22px 28px', position: 'relative', overflow: 'hidden',
        background: `linear-gradient(165deg, ${TABI_COLORS.bg} 0%, ${TABI_COLORS.bg2} 100%)`,
      }}>
        <div style={{ marginBottom: 12, display: 'flex', alignItems: 'center' }}>
          <div style={{
            fontSize: 11, fontWeight: 600, letterSpacing: '0.18em',
            color: TABI_COLORS.red, flex: 1,
          }}>旅 · TABI · 日本旅行</div>
          {(() => {
            let isCustom = false;
            try { isCustom = JSON.parse(localStorage.getItem('TABI_LLM_CONFIG') || '{}').mode === 'custom'; } catch {}
            return (
              <button onClick={() => setShowLLMSettings(true)} style={{
                display: 'flex', alignItems: 'center', gap: 4,
                padding: '4px 10px', borderRadius: 8,
                border: `1px solid ${isCustom ? TABI_COLORS.blue : TABI_COLORS.line2}`,
                background: isCustom ? `${TABI_COLORS.blue}12` : 'transparent',
                color: isCustom ? TABI_COLORS.blue : TABI_COLORS.ink3,
                fontSize: 10.5, fontWeight: 600, cursor: 'pointer',
              }}>
                <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                  <circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
                </svg>
                {isCustom ? '自訂 AI' : 'AI 設定'}
              </button>
            );
          })()}
        </div>
        <div style={{
          fontFamily: tabiSerif, fontSize: 32, fontWeight: 600,
          color: TABI_COLORS.ink, lineHeight: 1.2, letterSpacing: '-0.02em',
        }}>
          こんにちは、<br/>
          <span style={{ color: TABI_COLORS.red }}>{userName || '旅人'}</span>さん。
        </div>
        {/* big primary CTA */}
        <button onClick={() => setCamSheet(true)} style={{
          marginTop: 24, width: '100%', border: 'none', cursor: 'pointer',
          background: TABI_COLORS.ink, color: TABI_COLORS.bg,
          padding: '20px 22px', borderRadius: 20, display: 'flex',
          alignItems: 'center', gap: 16, textAlign: 'left',
          boxShadow: '0 14px 32px rgba(26,22,18,0.16)',
        }}>
          <div style={{
            width: 46, height: 46, borderRadius: 23,
            background: TABI_COLORS.red, display: 'grid', placeItems: 'center',
            color: '#fff',
          }}><CameraIcon size={23} active /></div>
          <div style={{ flex: 1 }}>
            <div style={{ fontSize: 16, fontWeight: 600, letterSpacing: '0.02em' }}>
              カメラで翻訳
            </div>
            <div style={{ fontSize: 12.5, opacity: 0.7, marginTop: 3 }}>
              對準菜單／看板，立即翻譯
            </div>
          </div>
          <div style={{ fontSize: 20, opacity: 0.5 }}>›</div>
        </button>
      </div>

      {/* feature grid */}
      <div style={{ padding: '18px 16px 8px' }}>
        <SectionLabel jp="機能" zh="主要功能" />
        <div style={{
          display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12,
        }}>
          {features.map(f => (
            <button key={f.id} onClick={() => f.id === 'receipt' ? setScanReceiptOpen(true) : go(f.id)} style={{
              border: 'none', cursor: 'pointer', textAlign: 'left',
              background: TABI_COLORS.card, borderRadius: 20, padding: '18px 16px',
              boxShadow: '0 2px 12px rgba(26,22,18,0.06)',
              display: 'flex', flexDirection: 'column', gap: 14, minHeight: 142,
            }}>
              <div style={{
                width: 40, height: 40, borderRadius: 14,
                background: `${f.tone}15`, color: f.tone,
                display: 'grid', placeItems: 'center',
              }}><f.Icon size={21} active /></div>
              <div>
                <div style={{
                  fontFamily: tabiSerif, fontSize: 16, fontWeight: 600,
                  color: TABI_COLORS.ink, letterSpacing: '-0.01em',
                }}>{f.zh}</div>
                <div style={{ fontSize: 10.5, color: f.tone, marginTop: 2, fontWeight: 500 }}>
                  {f.jp}
                </div>
                <div style={{ fontSize: 12, color: TABI_COLORS.ink3, marginTop: 7, lineHeight: 1.5 }}>
                  {f.desc}
                </div>
              </div>
            </button>
          ))}
        </div>
      </div>


      {/* common phrases */}
      <div style={{ padding: '18px 16px 4px' }}>
        <SectionLabel jp="よく使う" zh="常用句速查" />
        <div style={{
          background: TABI_COLORS.card, borderRadius: 18, marginTop: 10,
          overflow: 'hidden', border: `1px solid ${TABI_COLORS.line}`,
        }}>
          {sampledPhrases.length === 0 ? (
            <div style={{ padding: '20px 16px', textAlign: 'center', color: TABI_COLORS.ink3, fontSize: 13 }}>載入中…</div>
          ) : sampledPhrases.map((p, i) => (
            <div key={p.id || i} style={{
              padding: '15px 16px', display: 'flex', alignItems: 'center', gap: 10,
              borderTop: i ? `1px solid ${TABI_COLORS.line}` : 'none',
            }}>
              <div style={{
                fontSize: 10, fontWeight: 600, color: TABI_COLORS.red,
                background: `${TABI_COLORS.red}12`, borderRadius: 6,
                padding: '3px 7px', flexShrink: 0, letterSpacing: '0.03em', whiteSpace: 'nowrap',
              }}>{p.category || p.cat}</div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{
                  fontFamily: tabiSerif, fontSize: 14.5, color: TABI_COLORS.ink,
                  fontWeight: 500, lineHeight: 1.35,
                }}>{p.japanese || p.jp}</div>
                <div style={{ fontSize: 12.5, color: TABI_COLORS.ink3, marginTop: 3 }}>
                  {p.chinese || p.zh}
                </div>
              </div>
              <button onClick={() => handleSpeak(i, p.japanese || p.jp)} style={{
                width: 34, height: 34, borderRadius: 17, border: 'none', flexShrink: 0,
                background: (speaking === i || ttsPending === i) ? TABI_COLORS.red : `${TABI_COLORS.red}14`,
                color: (speaking === i || ttsPending === i) ? '#fff' : TABI_COLORS.red,
                display: 'grid', placeItems: 'center', cursor: 'pointer',
                transition: 'background 0.15s, color 0.15s',
              }}>{(speaking === i || ttsPending === i) ? <StopIcon size={13} /> : <PlayIcon size={13} />}</button>
            </div>
          ))}
          <button onClick={() => go('phrases')} style={{
            width: '100%', border: 'none', borderTop: `1px solid ${TABI_COLORS.line}`,
            background: 'transparent', padding: '13px 16px', cursor: 'pointer',
            fontSize: 12.5, fontWeight: 600, color: TABI_COLORS.red,
            display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
          }}>
            查看全部 {allPhrases.length > 0 ? `${allPhrases.length} 句` : ''} →
          </button>
        </div>
      </div>


      {scanReceiptOpen && (
        <ScanReceiptSheet
          trip={null}
          trips={homeTrips}
          onClose={() => setScanReceiptOpen(false)}
          onSaved={() => setScanReceiptOpen(false)}
          onTripCreated={() => loadHomeTrips()}
        />
      )}
      {showLLMSettings && (
        <LLMSettingsSheet onClose={() => setShowLLMSettings(false)} />
      )}
    </div>
  );
}


function couponCategories(value) {
  return String(value || '').split(',').map(v => v.trim()).filter(Boolean);
}

function couponCategoryText(value) {
  const cats = couponCategories(value);
  return cats.length ? cats.join('、') : '';
}

function formatCouponDate(value) {
  if (!value) return '無期限';
  const parts = String(value).split('-');
  if (parts.length !== 3) return `期限 ${value}`;
  return `期限 ${Number(parts[0])}/${Number(parts[1])}/${Number(parts[2])}`;
}

function CouponListCard({ coupon, onOpen }) {
  return (
    <button key={coupon.id} onClick={() => onOpen(coupon)} style={{
      width: '100%', border: `1px solid ${TABI_COLORS.line}`, cursor: 'pointer', textAlign: 'left',
      background: TABI_COLORS.card, borderRadius: 14, padding: 0, overflow: 'hidden',
      boxShadow: '0 8px 22px rgba(26,22,18,0.06)', display: 'grid',
      gridTemplateColumns: '96px 1fr 74px', minHeight: 104,
    }}>
      <div style={{
        background: `${TABI_COLORS.warn}10`, display: 'grid', placeItems: 'center',
        borderRight: `1px solid ${TABI_COLORS.line}`, minHeight: 104,
      }}>
        {coupon.image_url ? (
          <img src={coupon.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain', objectPosition: 'center center', padding: 10, display: 'block' }} />
        ) : (
          <div style={{
            width: 50, height: 50, borderRadius: 14, background: `${TABI_COLORS.red}12`, color: TABI_COLORS.red,
            display: 'grid', placeItems: 'center', fontSize: 24, fontWeight: 700,
          }}>券</div>
        )}
      </div>
      <div style={{ padding: '16px 14px', minWidth: 0, alignSelf: 'center' }}>
        {couponCategoryText(coupon.category) && (
          <div style={{ fontSize: 10.5, color: TABI_COLORS.red, fontWeight: 700, marginBottom: 5 }}>{couponCategoryText(coupon.category)}</div>
        )}
        <div style={{ fontFamily: tabiSerif, fontSize: 15.5, fontWeight: 600, color: TABI_COLORS.ink, lineHeight: 1.35 }}>
          {coupon.title}
        </div>
        {coupon.description && (
          <div style={{ fontSize: 12.5, color: TABI_COLORS.ink2, marginTop: 6, lineHeight: 1.45 }}>
            {coupon.description}
          </div>
        )}
        <div style={{ fontSize: 11.5, color: TABI_COLORS.ink3, marginTop: 8 }}>
          {formatCouponDate(coupon.expires_at)}
        </div>
      </div>
      <div style={{
        borderLeft: `1px dashed ${TABI_COLORS.line2}`, display: 'grid', placeItems: 'center',
        color: TABI_COLORS.ink, fontSize: 13, fontWeight: 700, lineHeight: 1.45, padding: '0 12px', textAlign: 'center',
      }}>
        立即<br/>領取
      </div>
    </button>
  );
}

function CouponsScreen({ go }) {
  const [coupons, setCoupons] = React.useState([]);
  const [query, setQuery] = React.useState('');
  const [category, setCategory] = React.useState('全部');
  const [page, setPage] = React.useState(1);
  const pageTopRef = React.useRef(null);
  const pageSize = 10;

  React.useEffect(() => {
    fetch('/api/coupons')
      .then(r => r.ok ? r.json() : [])
      .then(data => setCoupons(Array.isArray(data) ? data : []))
      .catch(() => setCoupons([]));
  }, []);

  React.useEffect(() => { setPage(1); }, [query, category]);

  function openCoupon(coupon) {
    const href = coupon.target_type === 'image' ? (coupon.target_url || coupon.image_url) : coupon.target_url;
    if (href) window.open(href, '_blank', 'noopener,noreferrer');
  }

  function goCouponPage(nextPage) {
    setPage(nextPage);
    requestAnimationFrame(() => {
      pageTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
    });
  }

  const cats = React.useMemo(() => {
    const seen = new Set();
    coupons.forEach(c => couponCategories(c.category).forEach(cat => seen.add(cat)));
    return ['全部', ...Array.from(seen)];
  }, [coupons]);

  const filtered = coupons.filter(c => {
    const q = query.trim().toLowerCase();
    const matchesCat = category === '全部' || couponCategories(c.category).includes(category);
    if (!matchesCat) return false;
    if (!q) return true;
    return [c.title, c.description, c.category].some(v => String(v || '').toLowerCase().includes(q));
  });
  const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
  const safePage = Math.min(page, totalPages);
  const pageItems = filtered.slice((safePage - 1) * pageSize, safePage * pageSize);

  return (
    <div ref={pageTopRef} style={{ background: TABI_COLORS.bg, minHeight: '100%', paddingBottom: 22 }}>
      <TabiTopBar title="優惠券" subtitle="COUPON · クーポン" onBack={() => go('home')} />

      <div style={{ padding: '14px 16px 0' }}>
        <div style={{
          background: TABI_COLORS.card, border: `1px solid ${TABI_COLORS.line}`, borderRadius: 14,
          padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 8,
        }}>
          <span style={{ color: TABI_COLORS.ink3, fontSize: 15 }}>⌕</span>
          <input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜尋店名、分類或優惠內容" style={{
            flex: 1, border: 'none', outline: 'none', background: 'transparent', color: TABI_COLORS.ink,
            fontSize: 14, fontFamily: tabiFont,
          }} />
          {query && (
            <button onClick={() => setQuery('')} style={{
              width: 26, height: 26, borderRadius: 13, border: 'none', background: TABI_COLORS.bg2,
              color: TABI_COLORS.ink3, cursor: 'pointer', fontSize: 16,
            }}>×</button>
          )}
        </div>
      </div>

      <div style={{ position: 'relative', padding: '13px 0 0' }}>
        <div style={{
          overflowX: 'auto', whiteSpace: 'nowrap', padding: '0 16px', scrollbarWidth: 'none', msOverflowStyle: 'none',
        }}>
          <div style={{ display: 'inline-flex', gap: 18, alignItems: 'center', paddingRight: 26 }}>
          {cats.map(cat => {
            const on = category === cat;
            return (
              <button key={cat} onClick={() => setCategory(cat)} style={{
                position: 'relative', border: 'none', background: 'transparent', cursor: 'pointer',
                color: on ? TABI_COLORS.ink : TABI_COLORS.ink3, fontSize: 15, fontWeight: on ? 700 : 600,
                padding: '8px 0 11px', fontFamily: tabiFont,
              }}>
                {cat}
                {on && <span style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 4, borderRadius: 2, background: TABI_COLORS.ink }} />}
              </button>
            );
          })}
          </div>
        </div>
        <div style={{ position: 'absolute', top: 13, left: 0, bottom: 0, width: 16, pointerEvents: 'none', background: `linear-gradient(90deg, ${TABI_COLORS.bg}, rgba(250,246,240,0))` }} />
        <div style={{ position: 'absolute', top: 13, right: 0, bottom: 0, width: 34, pointerEvents: 'none', background: `linear-gradient(270deg, ${TABI_COLORS.bg}, rgba(250,246,240,0))` }} />
      </div>

      <div style={{ padding: '12px 16px 0', display: 'flex', alignItems: 'center', color: TABI_COLORS.ink3, fontSize: 12 }}>
        <div>{filtered.length} 張優惠券</div>
        <div style={{ marginLeft: 'auto' }}>第 {safePage} / {totalPages} 頁</div>
      </div>

      <div style={{ padding: '12px 16px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
        {pageItems.length ? pageItems.map(coupon => (
          <CouponListCard key={coupon.id} coupon={coupon} onOpen={openCoupon} />
        )) : (
          <div style={{
            background: TABI_COLORS.card, border: `1px solid ${TABI_COLORS.line}`, borderRadius: 16,
            padding: '28px 18px', textAlign: 'center', color: TABI_COLORS.ink3, fontSize: 13,
          }}>找不到符合條件的優惠券</div>
        )}
      </div>

      {totalPages > 1 && (
        <div style={{ padding: '16px 16px 0', display: 'flex', gap: 10, alignItems: 'center', justifyContent: 'center' }}>
          <button disabled={safePage <= 1} onClick={() => goCouponPage(Math.max(1, safePage - 1))} style={{
            padding: '10px 16px', borderRadius: 10, border: `1px solid ${TABI_COLORS.line2}`,
            background: safePage <= 1 ? TABI_COLORS.bg2 : TABI_COLORS.card, color: safePage <= 1 ? TABI_COLORS.ink3 : TABI_COLORS.ink,
            fontWeight: 700, cursor: safePage <= 1 ? 'default' : 'pointer',
          }}>上一頁</button>
          <button disabled={safePage >= totalPages} onClick={() => goCouponPage(Math.min(totalPages, safePage + 1))} style={{
            padding: '10px 16px', borderRadius: 10, border: `1px solid ${TABI_COLORS.line2}`,
            background: safePage >= totalPages ? TABI_COLORS.bg2 : TABI_COLORS.card, color: safePage >= totalPages ? TABI_COLORS.ink3 : TABI_COLORS.ink,
            fontWeight: 700, cursor: safePage >= totalPages ? 'default' : 'pointer',
          }}>下一頁</button>
        </div>
      )}

    </div>
  );
}


function SectionLabel({ jp, zh, right }) {
  return (
    <div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
      <div style={{
        fontFamily: tabiSerif, fontSize: 16, fontWeight: 600,
        color: TABI_COLORS.ink, letterSpacing: '-0.01em',
      }}>{zh}</div>
      <div style={{
        fontSize: 11, color: TABI_COLORS.ink3, letterSpacing: '0.1em',
      }}>{jp}</div>
      {right && <div style={{ marginLeft: 'auto', fontSize: 11, color: TABI_COLORS.ink3 }}>{right}</div>}
    </div>
  );
}

// ─────────── CAMERA ───────────
function CameraScreen({ go, mode = 'menu', onMenuScanned, onSignScanned }) {
  const [files, setFiles]       = React.useState([]);
  const [previews, setPreviews] = React.useState([]);
  const [uploading, setUploading] = React.useState(false);
  const [uploadProgress, setUploadProgress] = React.useState('');
  const [error, setError]       = React.useState(null);
  const [alertModal, setAlertModal] = React.useState(null); // { emoji, title, desc }
  const cameraRef  = React.useRef();
  const galleryRef = React.useRef();

  const labels = {
    menu:    { jp: 'メニューを撮影', zh: '拍攝菜單' },
    receipt: { jp: 'レシートを撮影', zh: '拍攝發票' },
    sign:    { jp: '写真を翻訳',     zh: '拍照翻譯' },
  }[mode];

  const addFiles = (fileList) => {
    if (!fileList?.length) return;
    setError(null);
    setAlertModal(null);
    const arr = Array.from(fileList);
    const urls = arr.map(f => URL.createObjectURL(f));
    setFiles(prev => [...prev, ...arr]);
    setPreviews(prev => [...prev, ...urls]);
  };

  const removeFile = (i) => {
    URL.revokeObjectURL(previews[i]);
    setFiles(prev => prev.filter((_, idx) => idx !== i));
    setPreviews(prev => prev.filter((_, idx) => idx !== i));
  };

  const upload = async () => {
    if (!files.length || uploading) return;
    setUploading(true);
    setError(null);
    try {
      if (mode === 'sign') {
        const results = [];
        for (let i = 0; i < files.length; i++) {
          if (files.length > 1) setUploadProgress(`翻譯第 ${i + 1} 張，共 ${files.length} 張…`);
          const fd = new FormData();
          fd.append('image', files[i]);
          const r = await fetch('/api/scan/sign', {
            method: 'POST',
            headers: { Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() },
            body: fd,
          });
          if (!r.ok) {
            const err = await r.json().catch(() => ({}));
            if (err.detail === 'NO_TEXT_FOUND') {
              if (files.length === 1) {
                setAlertModal({ emoji: '🔍', title: '找不到文字', desc: '圖片中未找到可識別的文字，請拍攝含有路標、看板或文字的圖片' });
                return;
              }
              continue; // skip this photo but keep going
            }
            throw new Error(err.detail || `錯誤 ${r.status}`);
          }
          const data = await r.json();
          results.push({ ...data, image_url: previews[i] || null });
        }
        if (results.length === 0) {
          setAlertModal({ emoji: '🔍', title: '找不到文字', desc: '所有圖片中均未找到可識別的文字，請拍攝含有路標、看板或文字的圖片' });
          return;
        }
        const allSegments = results.flatMap(r => r.segments);
        if (onSignScanned) onSignScanned({
          photos:    results,
          image_url: results[0].image_url,
          segments:  allSegments,
          summary:   results.length === 1 ? results[0].summary : `共 ${results.length} 張照片`,
          note:      results[0].note,
        });
        go('signResult');
      } else {
        const fd = new FormData();
        for (const f of files) fd.append('images', f);
        fd.append('target_lang', 'ZH');
        const r = await fetch('/api/scan/menu', {
          method: 'POST',
          headers: { Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() },
          body: fd,
        });
        if (r.status !== 202) {
          const err = await r.json().catch(() => ({}));
          throw new Error(err.detail || `錯誤 ${r.status}`);
        }
        const start = await r.json();
        const jobId = start.job_id;
        if (!jobId) throw new Error('伺服器未回傳分析編號');

        const pollMs = 1500;
        const maxTries = 200;
        let data = null;
        for (let t = 0; t < maxTries; t++) {
          if (t > 0) await new Promise(res => setTimeout(res, pollMs));
          const pr = await fetch(`/api/scan/menu/jobs/${jobId}`, {
            headers: { Authorization: `Bearer ${window.tabiToken()}` },
          });
          if (!pr.ok) {
            const xe = await pr.json().catch(() => ({}));
            throw new Error(xe.detail || `輪詢錯誤 ${pr.status}`);
          }
          const st = await pr.json();
          if (st.status === 'completed') {
            data = st;
            break;
          }
          if (st.status === 'failed') {
            const d = st.detail || '';
            if (d === 'NOT_A_MENU') {
              setAlertModal({ emoji: '🍽️', title: '這不是菜單', desc: '請拍攝餐廳菜單，系統才能辨識料理項目並協助點餐' });
              return;
            }
            throw new Error(d || 'AI 分析失敗');
          }
          if (!['pending', 'running'].includes(st.status)) {
            throw new Error(st.detail || `未知狀態：${st.status}`);
          }
        }
        if (!data) throw new Error('AI 分析逾時，請稍後再試或減少圖片張數');

        if (onMenuScanned) onMenuScanned(data.menu_id);
        go('menuResult');
      }
    } catch (e) {
      setError(e.message);
    } finally {
      setUploading(false);
    }
  };

  const hasFiles = files.length > 0;

  return (
    <div style={{
      background: '#0E0B08', height: '100%', color: '#fff', display: 'flex', flexDirection: 'column',
    }}>
      <input ref={cameraRef} type="file" accept="image/*" capture="environment"
        style={{ display: 'none' }} onChange={e => { addFiles(e.target.files); e.target.value = ''; }} />
      <input ref={galleryRef} type="file" accept="image/*" multiple
        style={{ display: 'none' }} onChange={e => { addFiles(e.target.files); e.target.value = ''; }} />

      {/* header */}
      <div style={{
        padding: 'calc(env(safe-area-inset-top, 0px) + 14px) 18px 14px',
        display: 'flex', alignItems: 'center', gap: 10, zIndex: 5,
      }}>
        <button onClick={() => go('home')} style={{
          width: 36, height: 36, borderRadius: 18, border: 'none', cursor: 'pointer',
          background: 'rgba(255,255,255,0.14)', color: '#fff', fontSize: 18,
        }}>✕</button>
        <div style={{ flex: 1 }}>
          <div style={{ fontSize: 11, opacity: 0.65, letterSpacing: '0.14em', fontWeight: 600 }}>{labels.jp}</div>
          <div style={{ fontFamily: tabiSerif, fontSize: 17, fontWeight: 600 }}>{labels.zh}</div>
        </div>
        {files.length > 0 && (
          <div style={{
            fontSize: 12, padding: '4px 10px', borderRadius: 10,
            background: `${TABI_COLORS.red}33`, color: TABI_COLORS.red, fontWeight: 600,
          }}>{files.length} 張</div>
        )}
      </div>

      {/* preview area */}
      <div style={{
        flex: 1, overflowY: 'auto', position: 'relative',
        background: hasFiles ? '#111' : 'radial-gradient(circle at 50% 40%, #2A1F18 0%, #0E0B08 70%)',
        display: 'flex', flexDirection: 'column',
        alignItems: hasFiles ? 'stretch' : 'center',
        justifyContent: hasFiles ? 'flex-start' : 'center',
        padding: hasFiles ? 10 : 0,
      }}>
        {!hasFiles && (
          <div style={{ textAlign: 'center', opacity: 0.45, padding: '0 40px' }}>
            <div style={{ fontSize: 52, marginBottom: 14 }}>📷</div>
            <div style={{ fontSize: 13, lineHeight: 1.7 }}>
              點「拍照」直接拍攝<br/>
              點「相簿」可一次選多張<br/>
              <span style={{ opacity: 0.7, fontSize: 11 }}>菜單多頁可分開拍後一起送出</span>
            </div>
          </div>
        )}

        {hasFiles && (
          <div style={{
            display: 'grid',
            gridTemplateColumns: files.length === 1 ? '1fr' : 'repeat(2, 1fr)',
            gap: 8,
          }}>
            {previews.map((url, i) => (
              <div key={i} style={{ position: 'relative', borderRadius: 10, overflow: 'hidden' }}>
                <img src={url} alt="" style={{ width: '100%', height: 'auto', display: 'block' }} />
                <button onClick={() => removeFile(i)} style={{
                  position: 'absolute', top: 6, right: 6,
                  width: 24, height: 24, borderRadius: 12, border: 'none',
                  background: 'rgba(0,0,0,0.65)', color: '#fff', fontSize: 16,
                  cursor: 'pointer', display: 'grid', placeItems: 'center',
                }}>×</button>
                {files.length > 1 && (
                  <div style={{
                    position: 'absolute', bottom: 6, left: 6,
                    fontSize: 10, padding: '2px 7px', borderRadius: 6,
                    background: 'rgba(0,0,0,0.65)', color: '#fff',
                  }}>第 {i + 1} 頁</div>
                )}
              </div>
            ))}
          </div>
        )}

        {uploading && (
          <div style={{
            position: 'absolute', inset: 0, background: 'rgba(14,11,8,0.85)',
            display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16,
          }}>
            <div style={{
              width: 44, height: 44, borderRadius: 22,
              border: `3px solid rgba(200,85,61,0.3)`, borderTopColor: TABI_COLORS.red,
              animation: 'tabiSpin 0.8s linear infinite',
            }}/>
            <div style={{ fontSize: 14, opacity: 0.85 }}>
              {mode === 'sign'
                ? (uploadProgress || 'AI 翻譯中…')
                : `AI 分析菜單中${files.length > 1 ? `（共 ${files.length} 頁）` : ''}…`}
            </div>
          </div>
        )}

        {error && !uploading && (
          <div style={{
            position: 'absolute', bottom: 12, left: 12, right: 12,
            background: 'rgba(200,85,61,0.15)', border: `1px solid ${TABI_COLORS.red}`,
            borderRadius: 12, padding: '12px 14px', fontSize: 13, color: '#ff9580', textAlign: 'center',
          }}>{error}</div>
        )}

        {/* Alert modal — centered overlay for recognizable error types */}
        {alertModal && (
          <div style={{
            position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.55)',
            zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 28,
          }}>
            <div style={{
              background: TABI_COLORS.bg, borderRadius: 22, padding: '28px 22px',
              width: '100%', maxWidth: 300, textAlign: 'center',
            }}>
              <div style={{ fontSize: 44, marginBottom: 14 }}>{alertModal.emoji}</div>
              <div style={{ fontFamily: tabiSerif, fontSize: 19, fontWeight: 600, color: TABI_COLORS.ink }}>
                {alertModal.title}
              </div>
              <div style={{ fontSize: 13, color: TABI_COLORS.ink3, marginTop: 8, lineHeight: 1.65 }}>
                {alertModal.desc}
              </div>
              <button onClick={() => { setAlertModal(null); previews.forEach(u => URL.revokeObjectURL(u)); setFiles([]); setPreviews([]); }} style={{
                marginTop: 22, width: '100%', padding: '14px', borderRadius: 14, border: 'none',
                background: TABI_COLORS.ink, color: '#fff', fontSize: 14, fontWeight: 600, cursor: 'pointer',
              }}>重新拍攝</button>
            </div>
          </div>
        )}
      </div>

      {/* bottom bar */}
      <div style={{
        padding: '14px 18px calc(env(safe-area-inset-bottom, 0px) + 18px)',
        background: 'rgba(14,11,8,0.96)', display: 'flex', flexDirection: 'column', gap: 10,
      }}>
        {hasFiles && (
          <button onClick={upload} disabled={uploading} style={{
            width: '100%', padding: '15px', borderRadius: 16, border: 'none',
            background: uploading ? 'rgba(200,85,61,0.45)' : TABI_COLORS.red,
            color: '#fff', fontSize: 15, fontWeight: 600, cursor: uploading ? 'default' : 'pointer',
          }}>
            {uploading ? (uploadProgress || 'AI 翻譯中…') : mode === 'sign' ? `翻譯圖片 ›${files.length > 1 ? `（${files.length} 張）` : ''}` : `分析菜單 ›${files.length > 1 ? `（${files.length} 頁）` : ''}`}
          </button>
        )}
        <div style={{ display: 'flex', gap: 10 }}>
          <button onClick={() => cameraRef.current?.click()} style={{
            flex: 1, padding: '13px', borderRadius: 14,
            border: '1px solid rgba(255,255,255,0.2)', background: 'transparent',
            color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
            display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7,
          }}>
            <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
              <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
              <circle cx="12" cy="13" r="4"/>
            </svg>
            {hasFiles ? '再拍一頁' : '拍照'}
          </button>
          <button onClick={() => galleryRef.current?.click()} style={{
            flex: 1, padding: '13px', borderRadius: 14,
            border: '1px solid rgba(255,255,255,0.2)', background: 'transparent',
            color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
            display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7,
          }}>
            <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
              <rect x="3" y="3" width="18" height="18" rx="2"/>
              <circle cx="8.5" cy="8.5" r="1.5"/>
              <polyline points="21 15 16 10 5 21"/>
            </svg>
            {hasFiles ? '加更多' : '相簿'}
          </button>
        </div>
      </div>
    </div>
  );
}

// ─────────── MENU RESULT ───────────
function MenuResultScreen({ go, menuId, onOrderReady, targetLang = 'JA', setTargetLang, backTo = 'camera' }) {
  const [menu, setMenu]           = React.useState(null);
  const [loading, setLoading]     = React.useState(true);
  const [error, setError]         = React.useState(null);
  const [cart, setCart]           = React.useState(() => {
    try { return JSON.parse(localStorage.getItem(`tabi_cart_${menuId}`) || 'null')?.cart || {}; } catch (_) { return {}; }
  });

  const [localName, setLocalName] = React.useState('');
  const [editingName, setEditingName] = React.useState(false);
  const [pendingName, setPendingName] = React.useState('');
  const [imageUrl, setImageUrl]   = React.useState(null);
  const [uploadingImg, setUploadingImg] = React.useState(false);
  const imgInputRef = React.useRef();
  const [localTripId, setLocalTripId] = React.useState(undefined);
  const [trips, setTrips]             = React.useState(null);
  const [tripSheet, setTripSheet]     = React.useState(false);
  const [editMode, setEditMode]       = React.useState(false);
  const [editingItem, setEditingItem] = React.useState(null);
  const [savingItem, setSavingItem]   = React.useState(false);
  const [members, setMembers]         = React.useState([]);
  const [memberCart, setMemberCart]   = React.useState(() => {
    try { return JSON.parse(localStorage.getItem(`tabi_cart_${menuId}`) || 'null')?.memberCart || {}; } catch (_) { return {}; }
  });

  React.useEffect(() => {
    if (!menuId) { setError('菜單 ID 遺失'); setLoading(false); return; }
    fetch(`/api/menus/${menuId}`, {
      headers: { Authorization: `Bearer ${window.tabiToken()}` },
    })
      .then(r => { if (!r.ok) throw new Error(`錯誤 ${r.status}`); return r.json(); })
      .then(data => {
        setMenu(data);
        setLocalName(data.name);
        setImageUrl(data.image_url || null);
        setLocalTripId(data.trip_id || null);
        setLoading(false);
      })
      .catch(e => { setError(e.message); setLoading(false); });
  }, [menuId]);

  React.useEffect(() => {
    if (localTripId === undefined) return; // not loaded yet — don't clear restored cart
    if (!localTripId) { setMembers([]); setMemberCart({}); return; }
    fetch(`/api/trips/${localTripId}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
      .then(r => r.json())
      .then(d => setMembers(Array.isArray(d.members) ? d.members : []))
      .catch(() => setMembers([]));
  }, [localTripId]);

  // Persist cart to localStorage on every change
  React.useEffect(() => {
    if (!menuId) return;
    localStorage.setItem(`tabi_cart_${menuId}`, JSON.stringify({ cart, memberCart }));
  }, [cart, memberCart, menuId]);

  const saveName = async () => {
    const name = pendingName.trim();
    if (!name || !menuId) { setEditingName(false); return; }
    setLocalName(name);
    setEditingName(false);
    await fetch(`/api/menus/${menuId}`, {
      method: 'PATCH',
      headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ name }),
    }).catch(() => {});
  };

  const uploadImage = async (file) => {
    if (!file || !menuId) return;
    setUploadingImg(true);
    try {
      const fd = new FormData();
      fd.append('image', file);
      const r = await fetch(`/api/menus/${menuId}/image`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${window.tabiToken()}` },
        body: fd,
      });
      if (!r.ok) throw new Error();
      const data = await r.json();
      setImageUrl(data.image_url + '?t=' + Date.now());
    } catch (_) {}
    setUploadingImg(false);
  };

  const loadTrips = () => {
    if (trips !== null) return;
    fetch('/api/trips', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
      .then(r => r.json()).then(d => setTrips(Array.isArray(d) ? d : [])).catch(() => setTrips([]));
  };

  const tapMemberItem = (itemId, memberId) => {
    setMemberCart(prev => {
      const item = { ...(prev[itemId] || {}) };
      item[memberId] = (item[memberId] || 0) + 1;
      return { ...prev, [itemId]: item };
    });
  };

  const removeMemberFromItem = (itemId, memberId) => {
    setMemberCart(prev => {
      const item = { ...(prev[itemId] || {}) };
      const newQty = (item[memberId] || 0) - 1;
      if (newQty <= 0) delete item[memberId];
      else item[memberId] = newQty;
      const next = { ...prev };
      if (Object.keys(item).length === 0) delete next[itemId];
      else next[itemId] = item;
      return next;
    });
  };

  const linkTrip = async (tripId) => {
    setLocalTripId(tripId);
    await fetch(`/api/menus/${menuId}`, {
      method: 'PATCH',
      headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ trip_id: tripId }),
    }).catch(() => {});
  };

  const saveItem = async (data) => {
    if (!data.original_name.trim()) return;
    setSavingItem(true);
    try {
      if (data.id) {
        const r = await fetch(`/api/menus/${menuId}/items/${data.id}`, {
          method: 'PATCH',
          headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
          body: JSON.stringify({
            original_name:   data.original_name.trim(),
            translated_name: data.translated_name.trim() || null,
            price:           data.price !== '' ? parseInt(data.price) : null,
            category:        data.category || null,
          }),
        });
        if (r.ok) {
          const updated = await r.json();
          setMenu(m => ({ ...m, items: m.items.map(it => it.id === data.id ? { ...it, ...updated } : it) }));
        }
      } else {
        const r = await fetch(`/api/menus/${menuId}/items`, {
          method: 'POST',
          headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
          body: JSON.stringify({
            original_name:   data.original_name.trim(),
            translated_name: data.translated_name.trim() || null,
            price:           data.price !== '' ? parseInt(data.price) : null,
            category:        data.category || '主食',
          }),
        });
        if (r.ok) {
          const newIt = await r.json();
          setMenu(m => ({ ...m, items: [...m.items, newIt] }));
        }
      }
    } catch (_) {}
    setSavingItem(false);
    setEditingItem(null);
  };

  const deleteItem = async (itemId) => {
    await fetch(`/api/menus/${menuId}/items/${itemId}`, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${window.tabiToken()}` },
    }).catch(() => {});
    setMenu(m => ({ ...m, items: m.items.filter(it => it.id !== itemId) }));
    setEditingItem(null);
  };

  const setQty = (id, q) => {
    setCart(prev => {
      const next = { ...prev };
      if (q <= 0) delete next[id]; else next[id] = q;
      return next;
    });
  };

  const usingMembers = members.length > 0;
  const memberQtySum = (v) => Object.values(v).reduce((s, n) => s + n, 0);
  const totalQty = usingMembers
    ? Object.values(memberCart).reduce((a, v) => a + memberQtySum(v), 0)
    : Object.values(cart).reduce((a, b) => a + b, 0);
  const totalPrice = usingMembers
    ? Object.entries(memberCart).reduce((a, [id, v]) => {
        const it = menu?.items.find(i => i.id === id);
        return a + (it?.price || 0) * memberQtySum(v);
      }, 0)
    : Object.entries(cart).reduce((a, [id, q]) => {
        const it = menu?.items.find(i => i.id === id);
        return a + (it?.price || 0) * q;
      }, 0);

  const confirmOrder = () => {
    if (totalQty === 0 || !menu) return;
    const cartItems = usingMembers
      ? Object.entries(memberCart)
          .filter(([_, v]) => memberQtySum(v) > 0)
          .map(([id, v]) => {
            const it = menu.items.find(i => i.id === id);
            return { menu_item_id: it.id, name: it.translated_name || it.original_name, jp_name: it.original_name, quantity: memberQtySum(v), price: it.price, member_ids: Object.keys(v), member_qtys: v };
          })
      : Object.entries(cart).map(([id, qty]) => {
          const it = menu.items.find(i => i.id === id);
          return { menu_item_id: it.id, name: it.translated_name || it.original_name, jp_name: it.original_name, quantity: qty, price: it.price };
        });
    if (onOrderReady) onOrderReady(cartItems, localTripId, members);
    go('orderConfirm');
  };

  if (loading) return (
    <div style={{ background: TABI_COLORS.bg, height: '100%', display: 'flex', flexDirection: 'column' }}>
      <TabiTopBar title="菜單翻譯" subtitle="MENU" onBack={() => go(backTo)} />
      <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 14, color: TABI_COLORS.ink2 }}>
        <div style={{ width: 36, height: 36, borderRadius: 18, border: `3px solid ${TABI_COLORS.line2}`, borderTopColor: TABI_COLORS.red, animation: 'tabiSpin 0.8s linear infinite' }}/>
        <div style={{ fontSize: 13 }}>載入菜單中…</div>
      </div>
    </div>
  );

  if (error) return (
    <div style={{ background: TABI_COLORS.bg, height: '100%', display: 'flex', flexDirection: 'column' }}>
      <TabiTopBar title="菜單翻譯" subtitle="MENU" onBack={() => go(backTo)} />
      <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, textAlign: 'center', color: TABI_COLORS.red }}>
        <div>
          <div style={{ fontSize: 32, marginBottom: 12 }}>⚠️</div>
          <div style={{ fontSize: 14 }}>{error}</div>
          <button onClick={() => go(backTo)} style={{ marginTop: 20, padding: '12px 24px', borderRadius: 12, border: 'none', background: TABI_COLORS.ink, color: '#fff', cursor: 'pointer', fontSize: 13 }}>返回</button>
        </div>
      </div>
    </div>
  );

  return (
    <div style={{ background: TABI_COLORS.bg, height: '100%', display: 'flex', flexDirection: 'column' }}>
      <TabiTopBar
        title="菜單翻譯"
        subtitle="MENU · メニュー翻訳"
        onBack={() => go(backTo)}
        right={!editMode && (
          <button onClick={() => setEditMode(true)} style={{
            display: 'flex', alignItems: 'center', gap: 5,
            padding: '6px 13px', borderRadius: 20,
            border: `1px solid ${TABI_COLORS.line2}`,
            background: 'transparent', color: TABI_COLORS.ink2,
            fontSize: 12, fontWeight: 600, cursor: 'pointer',
          }}>
            <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
            編輯菜單
          </button>
        )}
      />

      {/* ── Trip picker sheet ── */}
      {tripSheet && ReactDOM.createPortal(
        <div onClick={() => setTripSheet(false)} style={{
          position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
          zIndex: 9200, display: 'flex', alignItems: 'flex-end',
          animation: 'tabiFade 0.2s ease',
        }}>
          <div onClick={e => e.stopPropagation()} style={{
            width: '100%', maxWidth: 480, margin: '0 auto',
            background: TABI_COLORS.bg, borderRadius: '20px 20px 0 0',
            padding: '12px 0 28px', maxHeight: '70vh', overflowY: 'auto',
            WebkitOverflowScrolling: 'touch',
          }}>
            <div style={{ width: 36, height: 4, borderRadius: 2, background: TABI_COLORS.line2, margin: '6px auto 14px' }}/>
            <div style={{ padding: '0 20px 12px' }}>
              <div style={{ fontFamily: tabiSerif, fontSize: 18, fontWeight: 600, color: TABI_COLORS.ink }}>選擇旅程</div>
              <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginTop: 2 }}>將此菜單關聯到旅程</div>
            </div>
            <button onClick={() => { linkTrip(null); setTripSheet(false); }} style={{
              width: '100%', border: 'none', cursor: 'pointer', textAlign: 'left',
              background: !localTripId ? `${TABI_COLORS.red}10` : 'transparent',
              padding: '12px 20px', display: 'flex', alignItems: 'center', gap: 14,
            }}>
              <div style={{ width: 36, height: 36, borderRadius: 10, background: TABI_COLORS.bg2, display: 'grid', placeItems: 'center', fontSize: 16, color: TABI_COLORS.ink3 }}>—</div>
              <div style={{ flex: 1, fontFamily: tabiSerif, fontSize: 14, fontWeight: 600, color: TABI_COLORS.ink }}>不連結旅程</div>
              {!localTripId && <div style={{ width: 20, height: 20, borderRadius: 10, background: TABI_COLORS.red, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 11 }}>✓</div>}
            </button>
            {trips === null ? (
              <div style={{ padding: '20px', textAlign: 'center', color: TABI_COLORS.ink3, fontSize: 13 }}>載入中…</div>
            ) : trips.length === 0 ? (
              <div style={{ padding: '12px 20px', fontSize: 12, color: TABI_COLORS.ink3 }}>還沒有旅程，請先在旅行頁建立旅程</div>
            ) : trips.map(t => (
              <button key={t.id} onClick={() => { linkTrip(t.id); setTripSheet(false); }} style={{
                width: '100%', border: 'none', cursor: 'pointer', textAlign: 'left',
                background: localTripId === t.id ? `${TABI_COLORS.red}10` : 'transparent',
                padding: '12px 20px', display: 'flex', alignItems: 'center', gap: 14,
              }}>
                <div style={{
                  width: 36, height: 36, borderRadius: 10, flexShrink: 0,
                  background: t.cover_color || TABI_COLORS.red,
                  display: 'grid', placeItems: 'center', fontSize: 16, color: '#fff',
                }}>✈</div>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ fontFamily: tabiSerif, fontSize: 14, fontWeight: 600, color: TABI_COLORS.ink }}>{t.name}</div>
                  {t.destination && <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginTop: 1 }}>{t.destination}</div>}
                </div>
                {localTripId === t.id && <div style={{ width: 20, height: 20, borderRadius: 10, background: TABI_COLORS.red, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 11 }}>✓</div>}
              </button>
            ))}
          </div>
        </div>,
        document.body
      )}

      {/* ── Item edit/add sheet ── */}
      {editingItem !== null && ReactDOM.createPortal(
        <div onClick={() => setEditingItem(null)} style={{
          position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
          zIndex: 9200, display: 'flex', alignItems: 'flex-end',
          animation: 'tabiFade 0.2s ease',
        }}>
          <div onClick={e => e.stopPropagation()} style={{
            width: '100%', maxWidth: 480, margin: '0 auto',
            background: TABI_COLORS.bg, borderRadius: '20px 20px 0 0',
            padding: '12px 0 calc(env(safe-area-inset-bottom, 0px) + 24px)',
            maxHeight: '85vh', overflowY: 'auto', WebkitOverflowScrolling: 'touch',
          }}>
            <div style={{ width: 36, height: 4, borderRadius: 2, background: TABI_COLORS.line2, margin: '6px auto 14px' }}/>
            <div style={{ padding: '0 20px 16px' }}>
              <div style={{ fontFamily: tabiSerif, fontSize: 18, fontWeight: 600, color: TABI_COLORS.ink }}>
                {editingItem.id ? '編輯品項' : '新增品項'}
              </div>
            </div>
            <div style={{ padding: '0 20px' }}>
              {[
                { label: '原文名稱', key: 'original_name', placeholder: '原文（日文、英文等）' },
                { label: '中文譯名', key: 'translated_name', placeholder: '中文名稱（可留空）' },
                { label: '價格（円）', key: 'price', placeholder: '0', inputMode: 'numeric' },
              ].map(({ label, key, placeholder, inputMode }) => (
                <div key={key} style={{ marginBottom: 12 }}>
                  <div style={{ fontSize: 11, fontWeight: 600, color: TABI_COLORS.ink3, marginBottom: 4 }}>{label}</div>
                  <input
                    value={editingItem[key]}
                    onChange={e => setEditingItem(prev => ({ ...prev, [key]: e.target.value }))}
                    inputMode={inputMode}
                    placeholder={placeholder}
                    style={{
                      width: '100%', padding: '10px 12px', borderRadius: 10,
                      border: `1px solid ${TABI_COLORS.line2}`,
                      background: TABI_COLORS.card, color: TABI_COLORS.ink,
                      fontSize: 14, fontFamily: 'inherit', outline: 'none',
                      boxSizing: 'border-box',
                    }}
                  />
                </div>
              ))}
              <div style={{ marginBottom: 16 }}>
                <div style={{ fontSize: 11, fontWeight: 600, color: TABI_COLORS.ink3, marginBottom: 6 }}>分類</div>
                <div style={{ display: 'flex', gap: 8 }}>
                  {['主食', '飲品'].map(cat => (
                    <button key={cat} onClick={() => setEditingItem(prev => ({ ...prev, category: cat }))} style={{
                      flex: 1, padding: '10px', borderRadius: 10, cursor: 'pointer', fontWeight: 600, fontSize: 13,
                      border: editingItem.category === cat ? 'none' : `1px solid ${TABI_COLORS.line2}`,
                      background: editingItem.category === cat ? TABI_COLORS.ink : 'transparent',
                      color: editingItem.category === cat ? '#fff' : TABI_COLORS.ink2,
                    }}>{cat}</button>
                  ))}
                </div>
              </div>
              <div style={{ display: 'flex', gap: 8 }}>
                {editingItem.id && (
                  <button onClick={() => deleteItem(editingItem.id)} style={{
                    padding: '13px 16px', borderRadius: 12, border: `1px solid ${TABI_COLORS.line2}`,
                    background: 'transparent', color: TABI_COLORS.red, fontSize: 13, fontWeight: 600, cursor: 'pointer',
                  }}>刪除</button>
                )}
                <button onClick={() => saveItem(editingItem)} disabled={savingItem || !editingItem.original_name.trim()} style={{
                  flex: 1, padding: '13px', borderRadius: 12, border: 'none',
                  background: (savingItem || !editingItem.original_name.trim()) ? 'rgba(26,22,18,0.35)' : TABI_COLORS.ink,
                  color: '#fff', fontSize: 14, fontWeight: 600, cursor: 'pointer',
                }}>{savingItem ? '儲存中…' : '儲存'}</button>
              </div>
            </div>
          </div>
        </div>,
        document.body
      )}

      {/* ── Edit toolbar — only shown in edit mode, pinned below title bar ── */}
      {editMode && (
        <div style={{
          flexShrink: 0,
          display: 'flex', alignItems: 'center', gap: 8,
          padding: '7px 16px',
          background: `${TABI_COLORS.red}10`,
          borderBottom: `1px solid ${TABI_COLORS.red}30`,
        }}>
          <button onClick={() => setEditingItem({ id: null, original_name: '', translated_name: '', price: '', category: '主食' })} style={{
            display: 'flex', alignItems: 'center', gap: 5,
            padding: '7px 14px', borderRadius: 20, border: 'none', cursor: 'pointer',
            background: TABI_COLORS.red, color: '#fff', fontSize: 13, fontWeight: 600,
          }}>
            <span style={{ fontSize: 15, lineHeight: 1 }}>＋</span> 新增品項
          </button>
          <div style={{ flex: 1, fontSize: 12, color: TABI_COLORS.red, fontWeight: 600 }}>編輯模式</div>
          <button onClick={() => setEditMode(false)} style={{
            padding: '7px 16px', borderRadius: 20, border: 'none', cursor: 'pointer',
            background: TABI_COLORS.ink, color: '#fff', fontSize: 13, fontWeight: 600,
          }}>完成</button>
        </div>
      )}

      <div style={{ flex: 1, overflowY: 'auto', paddingBottom: 140 }}>
        {/* ── Restaurant header card ── */}
        <input
          ref={imgInputRef} type="file" accept="image/*" style={{ display: 'none' }}
          onChange={e => { const f = e.target.files?.[0]; if (f) uploadImage(f); e.target.value = ''; }}
        />
        <div style={{ margin: '10px 16px', borderRadius: 18, overflow: 'hidden', border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.card }}>
          {/* image area */}
          <div
            onClick={() => imgInputRef.current?.click()}
            style={{
              position: 'relative', height: 148, cursor: 'pointer', overflow: 'hidden',
              background: imageUrl ? '#000' : `linear-gradient(135deg, ${TABI_COLORS.bg2} 0%, ${TABI_COLORS.line2} 100%)`,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
            }}
          >
            {imageUrl ? (
              <img src={imageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
            ) : (
              <div style={{ textAlign: 'center', padding: '0 20px', pointerEvents: 'none' }}>
                <div style={{ fontFamily: tabiSerif, fontSize: 20, fontWeight: 600, color: TABI_COLORS.ink, lineHeight: 1.3 }}>
                  {localName || '未命名店家'}
                </div>
                <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginTop: 8 }}>點此拍攝或上傳店家照片</div>
              </div>
            )}
            {uploadingImg && (
              <div style={{
                position: 'absolute', inset: 0, background: 'rgba(250,246,240,0.8)',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
              }}>
                <div style={{ width: 28, height: 28, borderRadius: 14, border: `2px solid ${TABI_COLORS.line2}`, borderTopColor: TABI_COLORS.red, animation: 'tabiSpin 0.8s linear infinite' }}/>
              </div>
            )}
            <div style={{
              position: 'absolute', bottom: 8, right: 8,
              width: 28, height: 28, borderRadius: 14,
              background: 'rgba(26,22,18,0.55)', color: '#fff',
              display: 'grid', placeItems: 'center',
            }}>
              <CameraIcon size={14} />
            </div>
          </div>

          {/* name row */}
          <div style={{ padding: '10px 14px 12px' }}>
            {editingName ? (
              <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
                <input
                  autoFocus
                  value={pendingName}
                  onChange={e => setPendingName(e.target.value)}
                  onKeyDown={e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditingName(false); }}
                  style={{
                    flex: 1, minWidth: 0, fontFamily: tabiSerif, fontSize: 16, fontWeight: 600,
                    border: `1.5px solid ${TABI_COLORS.red}`, borderRadius: 10,
                    padding: '6px 10px', background: TABI_COLORS.bg, color: TABI_COLORS.ink,
                    outline: 'none',
                  }}
                />
                <button onClick={saveName} style={{
                  width: 34, height: 34, borderRadius: 10, border: 'none', flexShrink: 0,
                  background: TABI_COLORS.ink, color: '#fff', cursor: 'pointer',
                  display: 'grid', placeItems: 'center',
                }}>
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
                </button>
                <button onClick={() => setEditingName(false)} style={{
                  width: 34, height: 34, borderRadius: 10, flexShrink: 0,
                  border: `1px solid ${TABI_COLORS.line2}`,
                  background: 'transparent', color: TABI_COLORS.ink2, cursor: 'pointer',
                  display: 'grid', placeItems: 'center',
                }}>
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
                </button>
              </div>
            ) : (
              <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                <div style={{ fontFamily: tabiSerif, fontSize: 16, fontWeight: 600, color: TABI_COLORS.ink, flex: 1, minWidth: 0 }}>
                  {localName || '未命名店家'}
                </div>
                <button
                  onClick={() => { setPendingName(localName); setEditingName(true); }}
                  style={{
                    width: 28, height: 28, borderRadius: 14, border: 'none', cursor: 'pointer',
                    background: `${TABI_COLORS.ink}0a`, color: TABI_COLORS.ink3,
                    display: 'grid', placeItems: 'center', flexShrink: 0,
                  }}
                >
                  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                    <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
                    <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
                  </svg>
                </button>
              </div>
            )}
            <div style={{ fontSize: 10.5, color: TABI_COLORS.ink3, marginTop: 3 }}>
              {menu.source_lang} 菜單 · {menu.items.length} 道料理
            </div>
          </div>
          {/* trip selector row */}
          <div style={{
            padding: '8px 14px 10px', borderTop: `1px solid ${TABI_COLORS.line}`,
            display: 'flex', alignItems: 'center', gap: 6,
          }}>
            <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={TABI_COLORS.ink3} strokeWidth="2"><path d="M17.657 16.657L13.414 20.9a1.998 1.998 0 0 1-2.827 0l-4.244-4.243a8 8 0 1 1 11.314 0z"/><circle cx="12" cy="11" r="3"/></svg>
            <div style={{ flex: 1, fontSize: 11.5, color: localTripId ? TABI_COLORS.ink2 : TABI_COLORS.ink3 }}>
              {localTripId
                ? (trips?.find(t => t.id === localTripId)?.name || '旅程已連結')
                : '未連結旅程'}
            </div>
            <button onClick={() => { loadTrips(); setTripSheet(true); }} style={{
              fontSize: 11, padding: '3px 10px', borderRadius: 8,
              border: `1px solid ${TABI_COLORS.line2}`,
              background: 'transparent', color: TABI_COLORS.ink2, cursor: 'pointer',
            }}>更改</button>
          </div>
        </div>

        {/* ── Menu items ── */}
        <div style={{ padding: '0 16px' }}>
          {menu.items.length === 0 && (
            <div style={{ textAlign: 'center', padding: '60px 24px', color: TABI_COLORS.ink3, fontSize: 13 }}>
              未能解析出菜單項目，請重新拍攝
            </div>
          )}
          {menu.items.map(it => {
            const q = usingMembers ? memberQtySum(memberCart[it.id] || {}) : (cart[it.id] || 0);
            return (
              <div key={it.id} style={{
                background: TABI_COLORS.card, borderRadius: 16, padding: '14px',
                marginTop: 10, position: 'relative',
                border: q ? `1.5px solid ${TABI_COLORS.red}` : `1px solid ${TABI_COLORS.line}`,
                transition: 'border-color 0.2s',
              }}>
                {editMode ? (
                  <button onClick={() => setEditingItem({
                    id: it.id, original_name: it.original_name,
                    translated_name: it.translated_name || '',
                    price: it.price ?? '', category: it.category || '主食',
                  })} style={{
                    position: 'absolute', top: 10, right: 10,
                    width: 26, height: 26, borderRadius: 13, border: 'none', cursor: 'pointer',
                    background: `${TABI_COLORS.ink}0f`, color: TABI_COLORS.ink3,
                    display: 'grid', placeItems: 'center',
                  }}>
                    <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
                  </button>
                ) : it.category && (
                  <div style={{
                    position: 'absolute', top: 10, right: 10,
                    fontSize: 9.5, padding: '2px 7px', borderRadius: 4,
                    background: `${TABI_COLORS.ink}0f`, color: TABI_COLORS.ink3,
                  }}>{it.category}</div>
                )}
                <div style={{ fontFamily: tabiSerif, fontSize: 15.5, fontWeight: 600, color: TABI_COLORS.ink, paddingRight: it.category ? 60 : 0 }}>
                  {it.translated_name || it.original_name}
                </div>
                <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginTop: 1 }}>
                  原文 · {it.original_name}
                </div>
                {it.translated_desc && (
                  <div style={{ fontSize: 11.5, color: TABI_COLORS.ink2, marginTop: 6, lineHeight: 1.4 }}>
                    {it.translated_desc}
                  </div>
                )}
                <div style={{ marginTop: 10 }}>
                  <div style={{ display: 'flex', alignItems: 'center' }}>
                    <div style={{ flex: 1 }}>
                      {it.price != null ? (
                        <>
                          <span style={{ fontFamily: tabiSerif, fontSize: 14, fontWeight: 600, color: TABI_COLORS.ink }}>¥{it.price.toLocaleString()}</span>
                          <span style={{ fontSize: 10.5, color: TABI_COLORS.ink3, marginLeft: 6 }}>≈ NT${Math.round(it.price * 0.21)}</span>
                        </>
                      ) : (
                        <span style={{ fontSize: 11, color: TABI_COLORS.ink3 }}>價格未標示</span>
                      )}
                    </div>
                    {!usingMembers && (
                      q === 0 ? (
                        <button onClick={() => setQty(it.id, 1)} style={{
                          width: 30, height: 30, borderRadius: 15, border: 'none', cursor: 'pointer',
                          background: TABI_COLORS.ink, color: '#fff', fontSize: 18, lineHeight: 1,
                          display: 'grid', placeItems: 'center',
                        }}>+</button>
                      ) : (
                        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                          <button onClick={() => setQty(it.id, q - 1)} style={qtyBtn}>−</button>
                          <span style={{ minWidth: 16, textAlign: 'center', fontWeight: 700, color: TABI_COLORS.ink }}>{q}</span>
                          <button onClick={() => setQty(it.id, q + 1)} style={{ ...qtyBtn, background: TABI_COLORS.red, color: '#fff', borderColor: TABI_COLORS.red }}>+</button>
                        </div>
                      )
                    )}
                  </div>
                  {usingMembers && !editMode && (
                    <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
                      {members.map(m => {
                        const mQty = memberCart[it.id]?.[m.id] || 0;
                        const sel = mQty > 0;
                        return (
                          <div key={m.id} style={{ display: 'inline-flex', alignItems: 'center' }}>
                            <button onClick={() => tapMemberItem(it.id, m.id)} style={{
                              padding: '4px 8px 4px 6px',
                              borderRadius: sel ? '20px 0 0 20px' : 20,
                              border: 'none', cursor: 'pointer',
                              background: sel ? (m.color || TABI_COLORS.red) : TABI_COLORS.bg2,
                              color: sel ? '#fff' : TABI_COLORS.ink2,
                              fontSize: 12, fontWeight: 600,
                              display: 'inline-flex', alignItems: 'center', gap: 5,
                            }}>
                              <span style={{
                                width: 18, height: 18, borderRadius: 9, flexShrink: 0,
                                background: sel ? 'rgba(255,255,255,0.3)' : (m.color || TABI_COLORS.red),
                                color: '#fff', fontSize: 9, fontWeight: 700,
                                display: 'inline-grid', placeItems: 'center',
                              }}>{m.initial || m.display_name?.[0]}</span>
                              {m.display_name}
                              {sel && mQty > 1 && (
                                <span style={{ fontSize: 10, fontWeight: 700, background: 'rgba(255,255,255,0.25)', borderRadius: 6, padding: '1px 4px' }}>×{mQty}</span>
                              )}
                            </button>
                            {sel && (
                              <button onClick={() => removeMemberFromItem(it.id, m.id)} style={{
                                padding: '4px 8px', border: 'none', cursor: 'pointer',
                                background: m.color || TABI_COLORS.red, color: '#fff',
                                borderRadius: '0 20px 20px 0',
                                fontSize: 13, fontWeight: 700, lineHeight: 1,
                                borderLeft: '1px solid rgba(255,255,255,0.25)',
                              }}>−</button>
                            )}
                          </div>
                        );
                      })}
                    </div>
                  )}
                </div>
              </div>
            );
          })}
        </div>
      </div>

      {totalQty > 0 && (
        <div style={{
          position: 'sticky', bottom: 0, zIndex: 20,
          padding: '8px 12px 12px',
          background: `linear-gradient(to top, ${TABI_COLORS.bg} 60%, rgba(250,246,240,0))`,
        }}>
          <div onClick={confirmOrder} style={{
            background: TABI_COLORS.ink, borderRadius: 18, padding: '14px 16px',
            color: '#fff', display: 'flex', alignItems: 'center', gap: 12,
            boxShadow: '0 16px 40px rgba(26,22,18,0.32)', cursor: 'pointer',
          }}>
            <div style={{
              width: 32, height: 32, borderRadius: 10, background: TABI_COLORS.red,
              display: 'grid', placeItems: 'center', fontSize: 13, fontWeight: 700,
            }}>{totalQty}</div>
            <div style={{ flex: 1 }}>
              <div style={{ fontSize: 12, opacity: 0.7 }}>共 {totalQty} 項</div>
              {totalPrice > 0 && (
                <div style={{ fontFamily: tabiSerif, fontSize: 17, fontWeight: 600 }}>
                  ¥{totalPrice.toLocaleString()}
                </div>
              )}
            </div>
            <div style={{
              padding: '8px 14px', borderRadius: 12, background: TABI_COLORS.red,
              fontSize: 13, fontWeight: 600,
            }}>確認點餐 ›</div>
          </div>
        </div>
      )}
    </div>
  );
}
const qtyBtn = {
  width: 28, height: 28, borderRadius: 14, border: `1px solid ${TABI_COLORS.line2}`,
  background: 'transparent', color: TABI_COLORS.ink, fontSize: 16, cursor: 'pointer',
  display: 'grid', placeItems: 'center', lineHeight: 1,
};

// ─────────── ORDER CONFIRM ───────────
const ORDER_LANGS = [
  { code: 'JA', label: '日文',     flag: '🇯🇵', tts: 'ja-JP' },
  { code: 'EN', label: '英文',     flag: '🇺🇸', tts: 'en-US' },
  { code: 'KO', label: '韓文',     flag: '🇰🇷', tts: 'ko-KR' },
  { code: 'TH', label: '泰文',     flag: '🇹🇭', tts: 'th-TH' },
  { code: 'VI', label: '越南文',   flag: '🇻🇳', tts: 'vi-VN' },
  { code: 'FR', label: '法文',     flag: '🇫🇷', tts: 'fr-FR' },
  { code: 'ES', label: '西班牙文', flag: '🇪🇸', tts: 'es-ES' },
  { code: 'IT', label: '義大利文', flag: '🇮🇹', tts: 'it-IT' },
  { code: 'DE', label: '德文',     flag: '🇩🇪', tts: 'de-DE' },
];

function OrderConfirmScreen({ go, orderItems = [], targetLang = 'JA', menuId, menuTripId, tripMembers = [] }) {
  const [sentence, setSentence] = React.useState(null);
  const [notes, setNotes]       = React.useState('');
  const [lang, setLang]         = React.useState(targetLang);
  const [generating, setGenerating] = React.useState(false);
  const [playing, setPlaying]   = React.useState(false);
  const [ttsPending, setTtsPending] = React.useState(false);
  const [bigCard, setBigCard]   = React.useState(false);
  const [flipped, setFlipped]   = React.useState(true);
  const [saving, setSaving]     = React.useState(false);
  const [expSheet, setExpSheet]     = React.useState(false);
  const [expTitle, setExpTitle]     = React.useState('餐費');
  const [expAmount, setExpAmount]   = React.useState('');
  const [expTripId, setExpTripId]   = React.useState(null);
  const [expTrips, setExpTrips]     = React.useState(null);
  const [expMembers, setExpMembers] = React.useState([]);
  const [expPaidBy, setExpPaidBy]   = React.useState(null);
  const [expSaving, setExpSaving]   = React.useState(false);
  const [expPayMode, setExpPayMode] = React.useState('together'); // together | equal | separate
  const [expSettled, setExpSettled] = React.useState({});         // { memberId: bool }
  const [expShowCreateTrip, setExpShowCreateTrip] = React.useState(false);

  const langMeta = ORDER_LANGS.find(l => l.code === lang) || ORDER_LANGS[0];

  function switchLang(code) {
    if (code === lang) return;
    setLang(code);
    setSentence(null); // clear old sentence when language changes
    _stopSpeech();
    setPlaying(false);
    setTtsPending(false);
  }

  const generateSentence = async () => {
    if (generating) return;
    setGenerating(true);
    try {
      const r = await fetch('/api/orders/sentence', {
        method: 'POST',
        headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          items: orderItems.map(i => ({ name: i.name, jp_name: i.jp_name, quantity: i.quantity })),
          target_lang: lang,
          notes: notes,
        }),
      });
      if (!r.ok) throw new Error(`錯誤 ${r.status}`);
      const data = await r.json();
      setSentence(data.sentence);
    } catch (e) {
      alert('生成失敗：' + e.message);
    } finally {
      setGenerating(false);
    }
  };

  const stopSpeak = () => {
    _stopSpeech();
    setPlaying(false);
    setTtsPending(false);
  };

  const speak = () => {
    if (!sentence) return;
    _playSpeech(sentence, langMeta.tts, {
      onGenerating: () => { setTtsPending(true); setPlaying(false); },
      onStart: () => { setTtsPending(false); setPlaying(true); },
      onEnd: () => { setTtsPending(false); setPlaying(false); },
      onError: () => { setTtsPending(false); setPlaying(false); },
    });
  };

  const total = orderItems.reduce((a, it) => a + (it.price || 0) * it.quantity, 0);

  const save = async () => {
    setSaving(true);
    try {
      await fetch('/api/orders', {
        method: 'POST',
        headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          menu_id: menuId || null,
          generated_sentence: sentence,
          notes: notes || null,
          items: orderItems.map(i => ({
            menu_item_id: i.menu_item_id || null,
            item_name: i.jp_name || i.name,
            quantity: i.quantity,
            price_snapshot: i.price || null,
          })),
        }),
      });
    } catch (_) {}
    go('home');
  };

  const calcMemberSplits = (members, totalAmt, forceEqual = false) => {
    if (!members?.length) return {};
    const shares = {};
    const amt = parseInt(totalAmt) || 0;
    if (forceEqual) {
      const n = members.length;
      const pp = Math.floor(amt / n);
      const rem = amt - pp * n;
      members.forEach((m, i) => { shares[m.id] = pp + (i === 0 ? rem : 0); });
    } else {
      const hasQtys = orderItems.some(i => i.member_qtys && Object.keys(i.member_qtys).length > 0);
      const hasIds  = orderItems.some(i => i.member_ids?.length > 0);
      if (hasQtys) {
        orderItems.forEach(item => {
          if (!item.member_qtys || !item.price) return;
          Object.entries(item.member_qtys).forEach(([mid, qty]) => {
            shares[mid] = (shares[mid] || 0) + item.price * qty;
          });
        });
      } else if (hasIds) {
        orderItems.forEach(item => {
          if (!item.member_ids?.length || !item.price) return;
          const pp = Math.floor(item.price / item.member_ids.length);
          const rem = item.price - pp * item.member_ids.length;
          item.member_ids.forEach((mid, idx) => {
            shares[mid] = (shares[mid] || 0) + pp + (idx === 0 ? rem : 0);
          });
        });
      } else {
        const n = members.length;
        const pp = Math.floor(amt / n);
        const rem = amt - pp * n;
        members.forEach((m, i) => { shares[m.id] = pp + (i === 0 ? rem : 0); });
      }
    }
    return shares;
  };

  const openExpSheet = () => {
    setExpAmount(total > 0 ? String(total) : '');
    setExpTripId(menuTripId || null);
    setExpPaidBy(null);
    setExpPayMode('together');
    setExpShowCreateTrip(false);
    setExpSettled({});
    const initMembers = menuTripId && tripMembers.length > 0 ? tripMembers : [];
    setExpMembers(initMembers);
    if (!expTrips) {
      fetch('/api/trips', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
        .then(r => r.json()).then(d => setExpTrips(Array.isArray(d) ? d : [])).catch(() => setExpTrips([]));
    }
    if (menuTripId && initMembers.length === 0) {
      fetch(`/api/trips/${menuTripId}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
        .then(r => r.json()).then(d => d.members && setExpMembers(d.members)).catch(() => {});
    }
    setExpSheet(true);
  };

  const loadExpMembers = (tripId) => {
    setExpPaidBy(null);
    setExpMembers([]);
    if (!tripId) return;
    fetch(`/api/trips/${tripId}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
      .then(r => r.json()).then(d => d.members && setExpMembers(d.members)).catch(() => {});
  };

  const selectExpTrip = (tripId) => {
    setExpTripId(tripId);
    loadExpMembers(tripId);
    if (menuId && tripId && tripId !== menuTripId) {
      fetch(`/api/menus/${menuId}`, {
        method: 'PATCH',
        headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ trip_id: tripId }),
      }).catch(() => {});
    }
  };


  const submitExpense = async () => {
    if (!expTripId || !expAmount) return;
    if ((expPayMode === 'together' || expPayMode === 'equal') && !expPaidBy) return;
    setExpSaving(true);
    const token = window.tabiToken();
    const splits = calcMemberSplits(expMembers, expAmount, expPayMode === 'equal');
    try {
      if (expPayMode === 'separate') {
        await Promise.all(
          Object.entries(splits).filter(([_, a]) => a > 0).map(([mid, amt]) => {
            const m = expMembers.find(m => m.id === mid);
            return fetch(`/api/trips/${expTripId}/expenses`, {
              method: 'POST',
              headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
              body: JSON.stringify({
                title: expTitle.trim() || '餐費',
                amount: amt, currency: 'JPY',
                paid_by: mid, split_method: 'solo', visibility: 'shared',
              }),
            });
          })
        );
      } else {
        const orderResp = await fetch('/api/orders', {
          method: 'POST',
          headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
          body: JSON.stringify({
            menu_id: menuId || null,
            generated_sentence: sentence,
            notes: notes || null,
            items: orderItems.map(i => ({
              menu_item_id: i.menu_item_id || null,
              item_name: i.jp_name || i.name,
              quantity: i.quantity,
              price_snapshot: i.price || null,
            })),
          }),
        });
        if (!orderResp.ok) throw new Error('儲存訂單失敗');
        const { id: orderId } = await orderResp.json();
        const expResp = await fetch(`/api/orders/${orderId}/to-expense`, {
          method: 'POST',
          headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
          body: JSON.stringify({
            trip_id: expTripId,
            title: expTitle.trim() || '餐費',
            amount: parseInt(expAmount) || 0,
            paid_by: expPaidBy,
            split_method: Object.keys(splits).length > 0 ? 'custom' : 'equal',
            splits: Object.keys(splits).length > 0 ? splits : null,
            settled_members: Object.entries(expSettled).filter(([,v]) => v).map(([k]) => k),
          }),
        });
        if (!expResp.ok) throw new Error('記帳失敗');
      }
      setExpSheet(false);
      go('home');
    } catch (e) {
      alert(e.message);
    } finally {
      setExpSaving(false);
    }
  };

  return (
    <div style={{ background: TABI_COLORS.bg, height: '100%', display: 'flex', flexDirection: 'column' }}>
      <TabiTopBar title="點餐確認" subtitle="ORDER · 注文" onBack={() => go('menuResult')} />

      <div style={{ flex: 1, overflowY: 'auto', padding: '14px 16px 32px' }}>

        {/* ── 點餐明細 ── */}
        <SectionLabel jp="ご注文" zh="您的點餐內容" />
        <div style={{
          background: TABI_COLORS.card, borderRadius: 16, marginTop: 10, overflow: 'hidden',
          border: `1px solid ${TABI_COLORS.line}`,
        }}>
          {orderItems.map((it, i) => {
            const memberEntries = it.member_qtys
              ? Object.entries(it.member_qtys).filter(([, q]) => q > 0)
              : [];
            const hasMemberQtys = memberEntries.length > 0;
            return (
              <div key={i} style={{
                padding: '12px 14px',
                borderTop: i ? `1px solid ${TABI_COLORS.line}` : 'none',
              }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                  <div style={{
                    width: 28, height: 28, borderRadius: 8, background: `${TABI_COLORS.red}14`,
                    color: TABI_COLORS.red, fontWeight: 700, fontSize: 12, flexShrink: 0,
                    display: 'grid', placeItems: 'center',
                  }}>×{it.quantity}</div>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontFamily: tabiSerif, fontSize: 14, fontWeight: 600, color: TABI_COLORS.ink }}>{it.name}</div>
                    <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginTop: 1 }}>{it.jp_name}</div>
                  </div>
                  {it.price != null && (
                    <div style={{ fontSize: 13, color: TABI_COLORS.ink, fontWeight: 600, flexShrink: 0 }}>
                      ¥{(it.price * it.quantity).toLocaleString()}
                    </div>
                  )}
                </div>
                {hasMemberQtys && (
                  <div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6, paddingLeft: 38 }}>
                    {memberEntries.map(([mid, qty]) => {
                      const m = tripMembers.find(m => m.id === mid);
                      if (!m) return null;
                      const perPrice = it.price != null ? it.price * qty : null;
                      return (
                        <div key={mid} style={{
                          display: 'inline-flex', alignItems: 'center', gap: 5,
                          background: TABI_COLORS.bg2, borderRadius: 20,
                          padding: '3px 10px 3px 5px',
                        }}>
                          <span style={{
                            width: 18, height: 18, borderRadius: 9, flexShrink: 0,
                            background: m.color || TABI_COLORS.red, color: '#fff',
                            fontSize: 9, fontWeight: 700, display: 'inline-grid', placeItems: 'center',
                          }}>{m.initial || m.display_name?.[0]}</span>
                          <span style={{ fontSize: 12, color: TABI_COLORS.ink2, fontWeight: 600 }}>{m.display_name}</span>
                          <span style={{ fontSize: 12, color: TABI_COLORS.ink3 }}>×{qty}</span>
                          {perPrice != null && (
                            <span style={{ fontSize: 12, color: TABI_COLORS.ink, fontWeight: 600 }}>¥{perPrice.toLocaleString()}</span>
                          )}
                        </div>
                      );
                    })}
                  </div>
                )}
              </div>
            );
          })}
          {total > 0 && (
            <div style={{
              padding: '12px 14px', display: 'flex', justifyContent: 'space-between',
              borderTop: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg2,
            }}>
              <div style={{ fontSize: 13, color: TABI_COLORS.ink2 }}>合計</div>
              <div style={{ fontFamily: tabiSerif, fontSize: 16, fontWeight: 700, color: TABI_COLORS.ink }}>
                ¥{total.toLocaleString()}
                <span style={{ fontSize: 11, color: TABI_COLORS.ink3, fontWeight: 400, marginLeft: 6 }}>≈ NT${Math.round(total * 0.21)}</span>
              </div>
            </div>
          )}
        </div>

        {/* ── 個人小計 ── */}
        {(() => {
          const hasQtys = orderItems.some(i => i.member_qtys && Object.keys(i.member_qtys).length > 0);
          if (!hasQtys || tripMembers.length === 0) return null;
          const perPerson = {};
          orderItems.forEach(item => {
            if (!item.member_qtys || item.price == null) return;
            Object.entries(item.member_qtys).forEach(([mid, qty]) => {
              perPerson[mid] = (perPerson[mid] || 0) + item.price * qty;
            });
          });
          const entries = Object.entries(perPerson).filter(([, v]) => v > 0);
          if (entries.length === 0) return null;
          return (
            <div style={{ marginTop: 14 }}>
              <SectionLabel jp="個別" zh="各自小計" />
              <div style={{
                background: TABI_COLORS.card, borderRadius: 16, marginTop: 10, overflow: 'hidden',
                border: `1px solid ${TABI_COLORS.line}`,
              }}>
                {entries.map(([mid, amt], i) => {
                  const m = tripMembers.find(m => m.id === mid);
                  if (!m) return null;
                  return (
                    <div key={mid} style={{
                      display: 'flex', alignItems: 'center', gap: 10,
                      padding: '11px 14px', borderTop: i ? `1px solid ${TABI_COLORS.line}` : 'none',
                    }}>
                      <span style={{
                        width: 26, height: 26, borderRadius: 13, flexShrink: 0,
                        background: m.color || TABI_COLORS.red, color: '#fff',
                        fontSize: 11, fontWeight: 700, display: 'inline-grid', placeItems: 'center',
                      }}>{m.initial || m.display_name?.[0]}</span>
                      <span style={{ flex: 1, fontSize: 14, color: TABI_COLORS.ink, fontWeight: 600 }}>{m.display_name}</span>
                      <span style={{ fontFamily: tabiSerif, fontSize: 15, fontWeight: 700, color: TABI_COLORS.ink }}>
                        ¥{amt.toLocaleString()}
                        <span style={{ fontSize: 11, color: TABI_COLORS.ink3, fontWeight: 400, marginLeft: 5 }}>≈ NT${Math.round(amt * 0.21)}</span>
                      </span>
                    </div>
                  );
                })}
              </div>
            </div>
          );
        })()}

        {/* ── 備註 ── */}
        <div style={{ marginTop: 18 }}>
          <SectionLabel jp="メモ" zh="額外備註（可選）" />
          <textarea
            value={notes}
            onChange={e => setNotes(e.target.value)}
            placeholder="例：不要香菜、辣度減半…"
            rows={2}
            style={{
              width: '100%', marginTop: 10, padding: '12px 14px',
              borderRadius: 12, border: `1px solid ${TABI_COLORS.line2}`,
              background: TABI_COLORS.card, color: TABI_COLORS.ink,
              fontSize: 13, fontFamily: 'inherit', resize: 'none',
              boxSizing: 'border-box', outline: 'none',
            }}
          />
          <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
            {['不要香菜', '不要洋蔥', '少冰', '辣度減半', '分開結帳'].map(t => (
              <button key={t} onClick={() => setNotes(n => n ? n + '，' + t : t)} style={{
                fontSize: 12, padding: '6px 12px', borderRadius: 999,
                border: `1px solid ${TABI_COLORS.line2}`, background: TABI_COLORS.card,
                color: TABI_COLORS.ink2, cursor: 'pointer',
              }}>+ {t}</button>
            ))}
          </div>
        </div>

        {/* ── 語言選擇 ── */}
        <div style={{ marginTop: 18 }}>
          <SectionLabel jp="言語を選択" zh="點餐語言" />
          <div style={{
            display: 'flex', gap: 7, overflowX: 'auto', marginTop: 10,
            paddingBottom: 4, scrollbarWidth: 'none', WebkitOverflowScrolling: 'touch',
          }}>
            {ORDER_LANGS.map(l => {
              const on = lang === l.code;
              return (
                <button key={l.code} onClick={() => switchLang(l.code)} style={{
                  flexShrink: 0, padding: '7px 13px', borderRadius: 20, border: 'none',
                  background: on ? TABI_COLORS.ink : TABI_COLORS.bg2,
                  color: on ? '#fff' : TABI_COLORS.ink2,
                  fontSize: 13, fontWeight: 600, cursor: 'pointer',
                  display: 'inline-flex', alignItems: 'center', gap: 5,
                  boxShadow: on ? '0 2px 10px rgba(26,22,18,0.18)' : 'none',
                  transition: 'background 0.15s',
                }}>
                  <span>{l.flag}</span>
                  <span>{l.label}</span>
                </button>
              );
            })}
          </div>
        </div>

        {/* ── 生成按鈕 ── */}
        <button onClick={generateSentence} disabled={generating} style={{
          width: '100%', marginTop: 16, padding: '15px', borderRadius: 16, border: 'none',
          background: generating ? 'rgba(26,22,18,0.35)' : TABI_COLORS.ink,
          color: '#fff', fontSize: 15, fontWeight: 600, cursor: generating ? 'default' : 'pointer',
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
        }}>
          {generating ? (
            <>
              <div style={{
                width: 18, height: 18, borderRadius: 9,
                border: '2px solid rgba(255,255,255,0.3)', borderTopColor: '#fff',
                animation: 'tabiSpin 0.8s linear infinite',
              }}/>
              AI 生成中…
            </>
          ) : sentence ? `重新生成（${langMeta.flag} ${langMeta.label}）` : `生成點餐句子 ✦`}
        </button>

        {/* ── 點餐句卡片 ── */}
        {sentence && (() => {
          const senLen = sentence.length;
          const bigFontSize = senLen > 50 ? 26 : senLen > 30 ? 36 : senLen > 18 ? 46 : 58;
          return (
            <>
              {bigCard && ReactDOM.createPortal(
                <div style={{
                  position: 'fixed', inset: 0, zIndex: 9500,
                  background: '#0d0d0d',
                  display: 'flex', flexDirection: 'column',
                  overflow: 'hidden',
                }}>
                  {/* top: original sentence summary */}
                  <div style={{
                    display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                    padding: '14px 18px 10px',
                  }}>
                    <div style={{ fontSize: 13, lineHeight: 1.5, color: 'rgba(255,255,255,0.35)', flex: 1, marginRight: 12 }}>
                      {orderItems.map(i => `${i.name}×${i.quantity}`).join('、')}
                    </div>
                    <div onClick={() => setBigCard(false)} style={{
                      width: 36, height: 36, borderRadius: 18, flexShrink: 0,
                      background: 'rgba(255,255,255,0.1)',
                      display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
                    }}>
                      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.7)" strokeWidth="2.5">
                        <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
                      </svg>
                    </div>
                  </div>

                  <div style={{ height: 1, background: 'rgba(255,255,255,0.07)', margin: '0 18px' }}/>

                  {/* big text */}
                  <div style={{
                    flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
                    padding: '24px 40px', cursor: 'pointer',
                  }} onClick={() => setFlipped(f => !f)}>
                    <div style={{
                      fontFamily: tabiSerif, fontSize: bigFontSize, fontWeight: 700,
                      lineHeight: 1.5, color: '#fff', textAlign: 'center',
                      transform: flipped ? 'rotate(180deg)' : 'none',
                      transition: 'transform 0.3s ease',
                    }}>
                      {sentence}
                    </div>
                  </div>

                  <div style={{ height: 1, background: 'rgba(255,255,255,0.07)', margin: '0 18px' }}/>

                  <div style={{ padding: '0 18px' }}>
                    <TTSInlineSettings lang={langMeta.tts} dark />
                  </div>

                  {/* bottom controls */}
                  <div style={{
                    display: 'flex', alignItems: 'center', gap: 10,
                    padding: '12px 18px calc(env(safe-area-inset-bottom, 0px) + 20px)',
                  }}>
                    <button onClick={(playing || ttsPending) ? stopSpeak : speak} style={{
                      display: 'inline-flex', alignItems: 'center', gap: 6,
                      padding: '9px 18px', borderRadius: 22, border: 'none', cursor: 'pointer',
                      background: (playing || ttsPending) ? TABI_COLORS.red : 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.85)',
                      fontSize: 13, fontWeight: 600,
                    }}>
                      {(playing || ttsPending) ? <StopIcon size={11}/> : <PlayIcon size={11}/>} {langMeta.flag} {ttsPending ? '語音生成中' : playing ? '停止' : '播放'}
                    </button>
                    <button onClick={() => setFlipped(f => !f)} style={{
                      display: 'inline-flex', alignItems: 'center', gap: 6,
                      padding: '9px 18px', borderRadius: 22, border: 'none', cursor: 'pointer',
                      background: flipped ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)',
                      color: 'rgba(255,255,255,0.85)', fontSize: 13, fontWeight: 600,
                    }}>
                      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
                        <polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/>
                        <path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4-4.64 4.36A9 9 0 0 1 3.51 15"/>
                      </svg>
                      {flipped ? '翻轉回來' : '翻轉給對方'}
                    </button>
                    <div style={{ flex: 1 }}/>
                    <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.25)', fontWeight: 600 }}>點 ✕ 關閉</div>
                  </div>
                </div>,
                document.body
              )}

              <div style={{
                marginTop: 16, background: TABI_COLORS.ink, color: '#fff', borderRadius: 22, padding: '20px',
              }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
                  <div style={{ fontSize: 11, letterSpacing: '0.15em', fontWeight: 600, color: TABI_COLORS.red }}>
                    給店員看 · 給店員聽
                  </div>
                  <div style={{
                    marginLeft: 'auto', fontSize: 12, fontWeight: 600,
                    background: 'rgba(255,255,255,0.12)', padding: '2px 10px', borderRadius: 8,
                    display: 'inline-flex', alignItems: 'center', gap: 4,
                  }}>
                    {langMeta.flag} {langMeta.label}
                  </div>
                </div>
                <div onClick={() => { setFlipped(true); setBigCard(true); }} style={{
                  fontFamily: tabiSerif, fontSize: 20, lineHeight: 1.6, fontWeight: 500,
                  cursor: 'pointer', position: 'relative',
                }}>
                  {sentence}
                  <span style={{ position: 'absolute', top: 0, right: 0, opacity: 0.4 }}>
                    <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
                      <polyline points="15 3 21 3 21 9"/>
                      <polyline points="9 21 3 21 3 15"/>
                      <line x1="21" y1="3" x2="14" y2="10"/>
                      <line x1="3" y1="21" x2="10" y2="14"/>
                    </svg>
                  </span>
                </div>
                <TTSInlineSettings lang={langMeta.tts} dark />
                <div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
                  <button onClick={(playing || ttsPending) ? stopSpeak : speak} style={{
                    flex: 1, padding: '13px', borderRadius: 14, border: 'none',
                    cursor: 'pointer',
                    background: TABI_COLORS.red,
                    color: '#fff', fontSize: 14, fontWeight: 600,
                    display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
                  }}>
                    {(playing || ttsPending) ? <StopIcon size={14} /> : <PlayIcon size={14} />}
                    {ttsPending ? '語音生成中' : playing ? '停止播放' : '播放給店員聽'}
                  </button>
                  <button onClick={() => { setFlipped(true); setBigCard(true); }} style={{
                    padding: '13px 16px', borderRadius: 14, border: 'none',
                    background: 'rgba(255,255,255,0.12)', color: '#fff',
                    fontSize: 14, fontWeight: 600, cursor: 'pointer',
                    display: 'flex', alignItems: 'center', gap: 6,
                  }}>
                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
                      <polyline points="15 3 21 3 21 9"/>
                      <polyline points="9 21 3 21 3 15"/>
                      <line x1="21" y1="3" x2="14" y2="10"/>
                      <line x1="3" y1="21" x2="10" y2="14"/>
                    </svg>
                    展開
                  </button>
                </div>
              </div>
            </>
          );
        })()}

        {/* ── 底部操作 ── */}
        <div style={{ display: 'flex', gap: 10, marginTop: 20 }}>
          <button onClick={() => go('menuResult')} style={{
            padding: '14px 16px', borderRadius: 14, border: `1px solid ${TABI_COLORS.line2}`,
            background: 'transparent', color: TABI_COLORS.ink, fontSize: 14, fontWeight: 600, cursor: 'pointer',
          }}>修改</button>
          <button onClick={openExpSheet} disabled={saving || expSaving} style={{
            padding: '14px 16px', borderRadius: 14, border: 'none',
            background: TABI_COLORS.green,
            color: '#fff', fontSize: 14, fontWeight: 600, cursor: 'pointer',
            display: 'flex', alignItems: 'center', gap: 5,
          }}>記帳 ›</button>
          <button onClick={save} disabled={saving || expSaving} style={{
            flex: 1, padding: '14px', borderRadius: 14, border: 'none',
            background: saving ? 'rgba(26,22,18,0.5)' : TABI_COLORS.ink,
            color: '#fff', fontSize: 14, fontWeight: 600, cursor: saving ? 'default' : 'pointer',
          }}>{saving ? '儲存中…' : '完成'}</button>
        </div>

      </div>

      {/* ── Expense recording sheet ── */}
      {expShowCreateTrip && (
        <CreateTripSheet
          onClose={() => setExpShowCreateTrip(false)}
          onCreated={(tripId) => {
            setExpShowCreateTrip(false);
            fetch('/api/trips', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
              .then(r => r.json())
              .then(d => { setExpTrips(Array.isArray(d) ? d : []); selectExpTrip(tripId); })
              .catch(() => {});
          }}
        />
      )}
      {expSheet && !expShowCreateTrip && ReactDOM.createPortal((() => {
        const splits = calcMemberSplits(expMembers, expAmount);
        const canConfirm = !expSaving && expTripId && expAmount &&
          (expPayMode === 'separate' ? expMembers.length > 0 : !!expPaidBy);
        const splitPreview = calcMemberSplits(expMembers, expAmount, expPayMode === 'equal');
        return (
          <div onClick={() => setExpSheet(false)} style={{
            position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
            zIndex: 9200, display: 'flex', alignItems: 'flex-end',
          }}>
            <div onClick={e => e.stopPropagation()} style={{
              width: '100%', maxWidth: 480, margin: '0 auto',
              background: TABI_COLORS.bg, borderRadius: '20px 20px 0 0',
              maxHeight: '85vh', overflowY: 'auto', WebkitOverflowScrolling: 'touch',
              paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 24px)',
            }}>
              <div style={{ width: 36, height: 4, borderRadius: 2, background: TABI_COLORS.line2, margin: '14px auto 6px' }}/>
              <div style={{ padding: '8px 20px 16px' }}>
                <div style={{ fontFamily: tabiSerif, fontSize: 20, fontWeight: 600, color: TABI_COLORS.ink }}>記帳</div>
              </div>
              <div style={{ padding: '0 20px' }}>
                {/* Title + Amount row */}
                <div style={{ display: 'flex', gap: 10, marginBottom: 14 }}>
                  <div style={{ flex: 2 }}>
                    <div style={{ fontSize: 11, fontWeight: 600, color: TABI_COLORS.ink3, marginBottom: 4 }}>名稱</div>
                    <input value={expTitle} onChange={e => setExpTitle(e.target.value)}
                      placeholder="餐費" style={{
                        width: '100%', padding: '11px 13px', borderRadius: 11,
                        border: `1px solid ${TABI_COLORS.line2}`, background: TABI_COLORS.card,
                        color: TABI_COLORS.ink, fontSize: 14, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box',
                      }}/>
                  </div>
                  <div style={{ flex: 1 }}>
                    <div style={{ fontSize: 11, fontWeight: 600, color: TABI_COLORS.ink3, marginBottom: 4 }}>金額（円）</div>
                    <input value={expAmount} onChange={e => setExpAmount(e.target.value)}
                      inputMode="numeric" placeholder="0" style={{
                        width: '100%', padding: '11px 13px', borderRadius: 11,
                        border: `1px solid ${TABI_COLORS.line2}`, background: TABI_COLORS.card,
                        color: TABI_COLORS.ink, fontSize: 14, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box',
                      }}/>
                  </div>
                </div>
                {/* Trip selector */}
                <div style={{ marginBottom: 14 }}>
                  <div style={{ fontSize: 11, fontWeight: 600, color: TABI_COLORS.ink3, marginBottom: 6 }}>旅程</div>
                  {!expTripId && (
                    <div style={{
                      background: `${TABI_COLORS.warn}22`, border: `1px solid ${TABI_COLORS.warn}80`,
                      borderRadius: 10, padding: '8px 12px', marginBottom: 10,
                      fontSize: 12, color: '#a07020', fontWeight: 600,
                    }}>請先選擇旅程才能記帳</div>
                  )}
                  {expTrips === null ? (
                    <div style={{ fontSize: 12, color: TABI_COLORS.ink3 }}>載入中…</div>
                  ) : (
                    <div>
                      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
                        {expTrips.map(t => (
                          <button key={t.id} onClick={() => selectExpTrip(t.id)} style={{
                            padding: '7px 14px', borderRadius: 20, cursor: 'pointer', fontWeight: 600, fontSize: 13, border: 'none',
                            background: expTripId === t.id ? TABI_COLORS.ink : TABI_COLORS.bg2,
                            color: expTripId === t.id ? '#fff' : TABI_COLORS.ink2,
                          }}>{t.name}</button>
                        ))}
                      </div>
                      <button onClick={() => setExpShowCreateTrip(true)} style={{
                        display: 'inline-flex', alignItems: 'center', gap: 4,
                        padding: '5px 12px', borderRadius: 20,
                        border: `1.5px dashed ${TABI_COLORS.line2}`,
                        background: 'transparent', color: TABI_COLORS.ink3,
                        fontSize: 12, fontWeight: 600, cursor: 'pointer',
                      }}>
                        <span style={{ fontSize: 14, lineHeight: 1 }}>＋</span> 新增旅程
                      </button>
                    </div>
                  )}
                </div>
                {/* Pay mode selector */}
                {expMembers.length > 0 && (
                  <div style={{ marginBottom: 14 }}>
                    <div style={{ fontSize: 11, fontWeight: 600, color: TABI_COLORS.ink3, marginBottom: 6 }}>付款方式</div>
                    <div style={{ display: 'flex', gap: 8 }}>
                      {[['together', '依點餐分'], ['equal', '平均分'], ['separate', '各自付款']].map(([mode, label]) => (
                        <button key={mode} onClick={() => { setExpPayMode(mode); setExpPaidBy(null); }} style={{
                          flex: 1, padding: '10px', borderRadius: 10, cursor: 'pointer', fontWeight: 600, fontSize: 12,
                          border: expPayMode === mode ? 'none' : `1px solid ${TABI_COLORS.line2}`,
                          background: expPayMode === mode ? TABI_COLORS.ink : 'transparent',
                          color: expPayMode === mode ? '#fff' : TABI_COLORS.ink2,
                        }}>{label}</button>
                      ))}
                    </div>
                    {expPayMode === 'separate' && (
                      <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginTop: 6 }}>
                        依照每人點的品項各自新增記帳，不計算欠款
                      </div>
                    )}
                    {expPayMode === 'equal' && (
                      <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginTop: 6 }}>
                        不管各自點什麼，總金額平均分攤給所有人
                      </div>
                    )}
                  </div>
                )}
                {/* Payer (together / equal mode) */}
                {expMembers.length > 0 && (expPayMode === 'together' || expPayMode === 'equal') && (
                  <div style={{ marginBottom: 14 }}>
                    <div style={{ fontSize: 11, fontWeight: 600, color: TABI_COLORS.ink3, marginBottom: 6 }}>誰先付帳？</div>
                    <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
                      {expMembers.map(m => (
                        <button key={m.id} onClick={() => setExpPaidBy(m.id)} style={{
                          padding: '7px 12px 7px 8px', borderRadius: 20, cursor: 'pointer', fontWeight: 600, fontSize: 13, border: 'none',
                          background: expPaidBy === m.id ? (m.color || TABI_COLORS.red) : TABI_COLORS.bg2,
                          color: expPaidBy === m.id ? '#fff' : TABI_COLORS.ink2,
                          display: 'inline-flex', alignItems: 'center', gap: 6,
                        }}>
                          <span style={{
                            width: 20, height: 20, borderRadius: 10, flexShrink: 0,
                            background: expPaidBy === m.id ? 'rgba(255,255,255,0.3)' : (m.color || TABI_COLORS.red),
                            color: '#fff', fontSize: 10, fontWeight: 700, display: 'inline-grid', placeItems: 'center',
                          }}>{m.initial || m.display_name?.[0]}</span>
                          {m.display_name}
                        </button>
                      ))}
                    </div>
                  </div>
                )}
                {/* Per-member split preview */}
                {expMembers.length > 0 && Object.keys(splitPreview).length > 0 && (
                  <div style={{ marginBottom: 14, background: TABI_COLORS.bg2, borderRadius: 12, padding: '10px 14px' }}>
                    <div style={{ fontSize: 11, fontWeight: 600, color: TABI_COLORS.ink3, marginBottom: 8 }}>
                      {expPayMode === 'separate' ? '各自記帳金額' : '各自負擔'}
                    </div>
                    {expMembers.map(m => {
                      const amt = splitPreview[m.id] || 0;
                      const isPayer = m.id === expPaidBy;
                      const isSettled = isPayer || !!expSettled[m.id];
                      const showToggle = expPayMode !== 'separate' && !isPayer;
                      return (
                        <div key={m.id} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
                          <span style={{
                            width: 20, height: 20, borderRadius: 10, flexShrink: 0,
                            background: m.color || TABI_COLORS.red, color: '#fff',
                            fontSize: 9, fontWeight: 700, display: 'inline-grid', placeItems: 'center',
                          }}>{m.initial || m.display_name?.[0]}</span>
                          <span style={{ flex: 1, fontSize: 13, color: TABI_COLORS.ink }}>{m.display_name}</span>
                          <span style={{ fontSize: 13, fontWeight: 600, color: TABI_COLORS.ink, fontFamily: tabiSerif, marginRight: 6 }}>
                            ¥{amt.toLocaleString()}
                          </span>
                          {isPayer ? (
                            <span style={{ fontSize: 10, fontWeight: 700, padding: '3px 7px', borderRadius: 6, background: `${m.color || TABI_COLORS.red}22`, color: m.color || TABI_COLORS.red, flexShrink: 0 }}>付款人</span>
                          ) : showToggle ? (
                            <button onClick={() => setExpSettled(s => ({ ...s, [m.id]: !s[m.id] }))} style={{
                              padding: '3px 8px', borderRadius: 6, border: 'none', cursor: 'pointer', flexShrink: 0,
                              background: isSettled ? TABI_COLORS.green : `${TABI_COLORS.warn}33`,
                              color: isSettled ? '#fff' : '#a07020', fontSize: 11, fontWeight: 700,
                            }}>{isSettled ? '✓ 已付' : '未付'}</button>
                          ) : null}
                        </div>
                      );
                    })}
                  </div>
                )}
                {/* Confirm */}
                <button onClick={submitExpense} disabled={!canConfirm} style={{
                  width: '100%', padding: '15px', borderRadius: 14, border: 'none',
                  background: canConfirm ? TABI_COLORS.green : 'rgba(26,22,18,0.3)',
                  color: '#fff', fontSize: 15, fontWeight: 600,
                  cursor: canConfirm ? 'pointer' : 'default',
                }}>
                  {expSaving ? '記帳中…' : expPayMode === 'separate' ? '各自記帳' : expPayMode === 'equal' ? '平均記帳' : '確認記帳'}
                </button>
              </div>
            </div>
          </div>
        );
      })(), document.body)}
    </div>
  );
}
function PlayingBars() {
  return (
    <span style={{ display: 'inline-flex', gap: 2, alignItems: 'flex-end', height: 14 }}>
      {[0, 1, 2, 3].map(i => (
        <span key={i} style={{
          width: 3, background: '#fff', borderRadius: 2,
          animation: `tabiBar 0.9s ${i * 0.12}s infinite ease-in-out`,
        }}/>
      ))}
    </span>
  );
}

// ─────────── VOICE CHAT ───────────
const VOICE_LANGS = [
  { code: 'ZH', zh: '中文',     name: '繁體中文',     tts: 'zh-TW', asr: 'zh', flag: '🇹🇼' },
  { code: 'JA', zh: '日文',     name: '日本語',       tts: 'ja-JP', asr: 'ja', flag: '🇯🇵' },
  { code: 'EN', zh: '英文',     name: 'English',      tts: 'en-US', asr: 'en', flag: '🇬🇧' },
  { code: 'KO', zh: '韓文',     name: '한국어',       tts: 'ko-KR', asr: 'ko', flag: '🇰🇷' },
  { code: 'TH', zh: '泰文',     name: 'ภาษาไทย',     tts: 'th-TH', asr: 'th', flag: '🇹🇭' },
  { code: 'VI', zh: '越南文',   name: 'Tiếng Việt',   tts: 'vi-VN', asr: 'vi', flag: '🇻🇳' },
  { code: 'FR', zh: '法文',     name: 'Français',     tts: 'fr-FR', asr: 'fr', flag: '🇫🇷' },
  { code: 'ES', zh: '西班牙文', name: 'Español',      tts: 'es-ES', asr: 'es', flag: '🇪🇸' },
  { code: 'IT', zh: '義大利文', name: 'Italiano',     tts: 'it-IT', asr: 'it', flag: '🇮🇹' },
  { code: 'DE', zh: '德文',     name: 'Deutsch',      tts: 'de-DE', asr: 'de', flag: '🇩🇪' },
];

function VoiceLangSheet({ open, current, onSelect, onClose, title }) {
  if (!open) return null;
  return (
    <div onClick={onClose} style={{
      position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.45)',
      zIndex: 200, display: 'flex', alignItems: 'flex-end',
    }}>
      <div onClick={e => e.stopPropagation()} style={{
        width: '100%', background: TABI_COLORS.bg, borderRadius: '20px 20px 0 0',
        padding: '12px 0 32px', maxHeight: '72%', overflowY: 'auto',
      }}>
        <div style={{ width: 36, height: 4, borderRadius: 2, background: TABI_COLORS.line2, margin: '6px auto 14px' }}/>
        <div style={{ padding: '0 20px 10px', fontFamily: tabiSerif, fontSize: 17, fontWeight: 600, color: TABI_COLORS.ink }}>
          {title}
        </div>
        {VOICE_LANGS.map(l => (
          <button key={l.code} onClick={() => { onSelect(l.code); onClose(); }} style={{
            width: '100%', border: 'none', cursor: 'pointer', textAlign: 'left',
            background: l.code === current ? `${TABI_COLORS.red}10` : 'transparent',
            padding: '13px 20px', display: 'flex', alignItems: 'center', gap: 12,
          }}>
            <span style={{ fontSize: 22 }}>{l.flag}</span>
            <div style={{ flex: 1 }}>
              <div style={{ fontFamily: tabiSerif, fontSize: 15, fontWeight: 600, color: TABI_COLORS.ink }}>{l.zh}</div>
              <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginTop: 1 }}>{l.name} · {l.code}</div>
            </div>
            {l.code === current && (
              <span style={{
                width: 22, height: 22, borderRadius: 11, background: TABI_COLORS.red,
                color: '#fff', display: 'grid', placeItems: 'center',
              }}><CheckIcon size={12}/></span>
            )}
          </button>
        ))}
      </div>
    </div>
  );
}

function VoiceChatScreen({ go }) {
  const [msgs, setMsgs]                 = React.useState([]);
  const [myLang, setMyLang]             = React.useState('ZH');
  const [theirLang, setTheirLang]       = React.useState('JA');
  const [langSheet, setLangSheet]       = React.useState(null); // 'me' | 'them'
  const [recording, setRecording]       = React.useState(null);
  const [transcribing, setTranscribing] = React.useState(null);
  const [translating, setTranslating]   = React.useState(null);
  const [myDraft, setMyDraft]           = React.useState('');
  const [themDraft, setThemDraft]       = React.useState('');
  const [error, setError]               = React.useState(null);
  const [saved, setSaved]               = React.useState(false);
  const [saving, setSaving]             = React.useState(false);
  const [pendingNav, setPendingNav]     = React.useState(null); // fn to call after confirm
  const mediaRecorderRef = React.useRef(null);
  const chunksRef        = React.useRef([]);
  const chatRef          = React.useRef(null);
  const msgsRef          = React.useRef(msgs);
  const savedRef         = React.useRef(saved);
  React.useEffect(() => { msgsRef.current = msgs; }, [msgs]);
  React.useEffect(() => { savedRef.current = saved; }, [saved]);

  React.useEffect(() => {
    if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
  }, [msgs, recording, transcribing, translating]);

  // Register navigation guard — intercepts tab changes while msgs exist
  React.useEffect(() => {
    window._tabiVoiceGuard = (navigateFn) => {
      if (msgsRef.current.length > 0 && !savedRef.current) {
        setPendingNav(() => navigateFn);
      } else {
        navigateFn();
      }
    };
    return () => { window._tabiVoiceGuard = null; };
  }, []);

  const guardedGo = (dest) => {
    if (msgs.length > 0 && !saved) {
      setPendingNav(() => () => go(dest));
    } else {
      go(dest);
    }
  };

  const saveConversation = async () => {
    if (saving || msgs.length === 0) return;
    setSaving(true);
    setError(null);
    try {
      // Generate title with LLM
      let title = msgs[0].orig.slice(0, 24);
      try {
        const tr = await fetch('/api/voice/summarize-title', {
          method: 'POST',
          headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json', ...window.tabiLLMHeaders() },
          body: JSON.stringify({
            messages: msgs.map(m => ({ direction: m.from === 'me' ? 'outbound' : 'inbound', original_text: m.orig })),
            source_lang: myLang,
            target_lang: theirLang,
          }),
        });
        if (tr.ok) { const d = await tr.json(); if (d.title) title = d.title; }
      } catch (_) { /* keep fallback title */ }

      const r1 = await fetch('/api/voice/conversations', {
        method: 'POST',
        headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ title, source_lang: myLang, target_lang: theirLang }),
      });
      if (!r1.ok) throw new Error('建立對話失敗');
      const { id } = await r1.json();
      await Promise.all(msgs.map(m =>
        fetch(`/api/voice/conversations/${id}/messages`, {
          method: 'POST',
          headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json' },
          body: JSON.stringify({
            direction: m.from === 'me' ? 'outbound' : 'inbound',
            original_text: m.orig,
            translated_text: m.tr,
          }),
        })
      ));
      setSaved(true);
    } catch (e) {
      setError('儲存失敗：' + e.message);
    }
    setSaving(false);
  };

  const callTranslate = async (text, from) => {
    if (!text.trim()) return;
    setTranslating(from);
    setError(null);
    try {
      const history = msgs.map(m => ({
        from_lang: m.from === 'me' ? myLang : theirLang,
        original: m.orig,
        translation: m.tr,
      }));
      const r = await fetch('/api/voice/translate', {
        method: 'POST',
        headers: { Authorization: `Bearer ${window.tabiToken()}`, 'Content-Type': 'application/json', ...window.tabiLLMHeaders() },
        body: JSON.stringify({
          text: text.trim(),
          from_lang: from === 'me' ? myLang : theirLang,
          to_lang:   from === 'me' ? theirLang : myLang,
          history,
        }),
      });
      if (!r.ok) throw new Error(`翻譯失敗 ${r.status}`);
      const data = await r.json();
      setMsgs(prev => [...prev, { from, orig: text.trim(), tr: data.translation }]);
      setSaved(false);
    } catch (e) {
      setError(e.message);
    }
    setTranslating(null);
  };

  const blobToWav = async (blob) => {
    const ctx = new AudioContext();
    const ab = await blob.arrayBuffer();
    const audioBuf = await ctx.decodeAudioData(ab);
    await ctx.close();
    const samples = audioBuf.getChannelData(0);
    const sr = audioBuf.sampleRate;
    const n = samples.length;
    const buf = new ArrayBuffer(44 + n * 2);
    const v = new DataView(buf);
    const ws = (off, s) => { for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)); };
    ws(0,'RIFF'); v.setUint32(4, 36 + n*2, true); ws(8,'WAVE');
    ws(12,'fmt '); v.setUint32(16,16,true); v.setUint16(20,1,true); v.setUint16(22,1,true);
    v.setUint32(24,sr,true); v.setUint32(28,sr*2,true); v.setUint16(32,2,true); v.setUint16(34,16,true);
    ws(36,'data'); v.setUint32(40, n*2, true);
    for (let i = 0; i < n; i++) {
      const s = Math.max(-1, Math.min(1, samples[i]));
      v.setInt16(44 + i*2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
    }
    return new Blob([buf], { type: 'audio/wav' });
  };

  const transcribeAndTranslate = async (blob, mimeType, side) => {
    setTranscribing(side);
    setError(null);
    let text;
    try {
      // Convert to WAV — many ASR APIs can't parse WebM duration without a full EBML parser
      let sendBlob = blob;
      try { sendBlob = await blobToWav(blob); } catch (_) { /* fallback to original */ }
      const fd = new FormData();
      const langCode = (side === 'me' ? myLang : theirLang);
      const langInfo = VOICE_LANGS.find(l => l.code === langCode) || VOICE_LANGS[0];
      fd.append('audio', sendBlob, 'audio.wav');
      fd.append('lang', langInfo.asr);
      const r = await fetch('/api/voice/transcribe', {
        method: 'POST',
        headers: { Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() },
        body: fd,
      });
      if (!r.ok) {
        const err = await r.json().catch(() => ({}));
        throw new Error(err.detail || `語音辨識失敗 ${r.status}`);
      }
      const data = await r.json();
      text = data.text?.trim();
    } catch (e) {
      setError(e.message);
      setTranscribing(null);
      return;
    }
    setTranscribing(null);
    if (!text) { setError('未偵測到內容，請再試一次'); return; }
    await callTranslate(text, side);
  };

  const startRecording = async (side) => {
    // Stop if already recording this side
    if (recording === side) { mediaRecorderRef.current?.stop(); return; }

    setError(null);
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const mimeType = ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus', '']
        .find(t => !t || MediaRecorder.isTypeSupported(t));
      const mr = new MediaRecorder(stream, mimeType ? { mimeType } : {});
      chunksRef.current = [];
      mr.ondataavailable = e => { if (e.data.size > 0) chunksRef.current.push(e.data); };
      mr.onstop = async () => {
        stream.getTracks().forEach(t => t.stop());
        setRecording(null);
        const blob = new Blob(chunksRef.current, { type: mr.mimeType || 'audio/webm' });
        if (blob.size < 500) { setError('錄音太短，請再試一次'); return; }
        await transcribeAndTranslate(blob, mr.mimeType, side);
      };
      mr.start(100);
      mediaRecorderRef.current = mr;
      setRecording(side);
    } catch (e) {
      if (e.name === 'NotAllowedError') setError('請允許麥克風權限後再試');
      else setError('無法啟動麥克風：' + e.message);
    }
  };

  const sendText = (side) => {
    const text = side === 'me' ? myDraft : themDraft;
    if (!text.trim() || translating || transcribing || recording) return;
    side === 'me' ? setMyDraft('') : setThemDraft('');
    callTranslate(text, side);
  };

  const myLangInfo    = VOICE_LANGS.find(l => l.code === myLang)    || VOICE_LANGS[0];
  const theirLangInfo = VOICE_LANGS.find(l => l.code === theirLang) || VOICE_LANGS[1];

  const rows = [
    { side: 'me',   draft: myDraft,   setDraft: setMyDraft,   placeholder: `輸入${myLangInfo.zh}…`,    tone: TABI_COLORS.red  },
    { side: 'them', draft: themDraft, setDraft: setThemDraft, placeholder: `輸入${theirLangInfo.zh}…`, tone: TABI_COLORS.blue },
  ];

  return (
    <div style={{ background: TABI_COLORS.bg, height: '100%', display: 'flex', flexDirection: 'column', position: 'relative' }}>
      <TabiTopBar
        title="即時對話"
        subtitle="VOICE · 会話通訳"
        onBack={() => guardedGo('home')}
        right={
          <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
            <button onClick={() => go('voiceHistory')} style={{
              padding: '5px 10px', borderRadius: 9, border: `1px solid ${TABI_COLORS.line2}`,
              background: 'transparent', color: TABI_COLORS.ink3, fontSize: 11, cursor: 'pointer',
            }}>紀錄</button>
            {msgs.length > 0 && !saved && (
              <button onClick={saveConversation} disabled={saving} style={{
                padding: '5px 11px', borderRadius: 9, border: 'none',
                background: TABI_COLORS.ink, color: '#fff', fontSize: 11, fontWeight: 600, cursor: 'pointer',
                opacity: saving ? 0.6 : 1,
              }}>{saving ? '儲存…' : '儲存'}</button>
            )}
            {saved && (
              <span style={{ fontSize: 11, color: TABI_COLORS.green, fontWeight: 600 }}>✓ 已儲存</span>
            )}
          </div>
        }
      />

      {/* lang bar — clickable to change language */}
      <div style={{ padding: '10px 16px 8px', display: 'flex', alignItems: 'center', gap: 8 }}>
        <button onClick={() => setLangSheet('me')} style={{
          display: 'flex', alignItems: 'center', gap: 6, padding: '7px 14px', borderRadius: 999,
          background: `${TABI_COLORS.red}12`, border: `1.5px solid ${TABI_COLORS.red}30`,
          color: TABI_COLORS.red, cursor: 'pointer',
        }}>
          <span style={{ fontSize: 16 }}>{myLangInfo.flag}</span>
          <span style={{ fontFamily: tabiSerif, fontSize: 13, fontWeight: 700 }}>{myLangInfo.zh}</span>
          <span style={{ fontSize: 9, opacity: 0.6 }}>▾</span>
        </button>
        <div style={{ flex: 1, height: 1, background: TABI_COLORS.line2 }}/>
        <span style={{ fontSize: 12, color: TABI_COLORS.ink3 }}>⇄</span>
        <div style={{ flex: 1, height: 1, background: TABI_COLORS.line2 }}/>
        <button onClick={() => setLangSheet('them')} style={{
          display: 'flex', alignItems: 'center', gap: 6, padding: '7px 14px', borderRadius: 999,
          background: `${TABI_COLORS.blue}12`, border: `1.5px solid ${TABI_COLORS.blue}30`,
          color: TABI_COLORS.blue, cursor: 'pointer',
        }}>
          <span style={{ fontSize: 16 }}>{theirLangInfo.flag}</span>
          <span style={{ fontFamily: tabiSerif, fontSize: 13, fontWeight: 700 }}>{theirLangInfo.zh}</span>
          <span style={{ fontSize: 9, opacity: 0.6 }}>▾</span>
        </button>
      </div>

      {/* leave-confirmation modal */}
      {pendingNav && (
        <div style={{
          position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.45)',
          zIndex: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
        }}>
          <div style={{ background: TABI_COLORS.bg, borderRadius: 20, padding: '22px 20px', width: '100%', maxWidth: 300 }}>
            <div style={{ fontFamily: tabiSerif, fontSize: 17, fontWeight: 600, color: TABI_COLORS.ink, marginBottom: 6 }}>
              離開對話？
            </div>
            <div style={{ fontSize: 13, color: TABI_COLORS.ink3, lineHeight: 1.6, marginBottom: 20 }}>
              對話尚未儲存，離開後將無法復原。
            </div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
              <button onClick={async () => {
                await saveConversation();
                setPendingNav(null);
                pendingNav();
              }} disabled={saving} style={{
                padding: '12px', borderRadius: 12, border: 'none', cursor: 'pointer',
                background: TABI_COLORS.ink, color: '#fff', fontSize: 14, fontWeight: 600,
                opacity: saving ? 0.6 : 1,
              }}>{saving ? '儲存中…' : '儲存並離開'}</button>
              <button onClick={() => { setPendingNav(null); pendingNav(); }} style={{
                padding: '12px', borderRadius: 12, border: `1px solid ${TABI_COLORS.line2}`,
                background: 'transparent', color: TABI_COLORS.ink2, fontSize: 14, cursor: 'pointer',
              }}>直接離開（不儲存）</button>
              <button onClick={() => setPendingNav(null)} style={{
                padding: '12px', borderRadius: 12, border: 'none',
                background: TABI_COLORS.red, color: '#fff', fontSize: 14, fontWeight: 600, cursor: 'pointer',
              }}>繼續對話</button>
            </div>
          </div>
        </div>
      )}

      {/* lang picker sheets */}
      <VoiceLangSheet
        open={langSheet === 'me'}
        current={myLang}
        onSelect={setMyLang}
        onClose={() => setLangSheet(null)}
        title="我說的語言"
      />
      <VoiceLangSheet
        open={langSheet === 'them'}
        current={theirLang}
        onSelect={setTheirLang}
        onClose={() => setLangSheet(null)}
        title="對方說的語言"
      />

      {/* chat area */}
      <div ref={chatRef} style={{ flex: 1, padding: '10px 14px 6px', overflowY: 'auto' }}>
        {msgs.length === 0 && (
          <div style={{ textAlign: 'center', padding: '40px 28px', color: TABI_COLORS.ink3 }}>
            <div style={{ fontSize: 34, marginBottom: 10 }}>🗣️</div>
            <div style={{ fontSize: 13, lineHeight: 1.8 }}>
              我方或對方點 🎤 錄音，或直接打字<br/>
              翻譯結果會大字顯示給對方看
            </div>
          </div>
        )}
        {msgs.map((m, i) => <Bubble key={i} m={m} myLang={myLang} theirLang={theirLang} />)}

      </div>

      {/* input panel — two clearly labelled sections */}
      <div style={{
        borderTop: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg,
        paddingBottom: 'calc(env(safe-area-inset-bottom,0px) + 6px)',
      }}>
        {rows.map((row, idx) => {
          const isRecording   = recording === row.side;
          const busySomething = !!translating || !!transcribing || (!!recording && !isRecording);
          const canSend       = !!row.draft.trim() && !translating && !transcribing && !recording;
          const langInfo      = row.side === 'me' ? myLangInfo : theirLangInfo;
          return (
            <div key={row.side} style={{
              borderTop: idx > 0 ? `1px solid ${TABI_COLORS.line}` : 'none',
            }}>
              {/* section label */}
              <div style={{
                padding: '7px 14px 3px', display: 'flex', alignItems: 'center', gap: 7,
              }}>
                <div style={{ width: 3, height: 13, borderRadius: 2, background: row.tone, flexShrink: 0 }}/>
                <span style={{ fontSize: 11.5, fontWeight: 700, color: row.tone, letterSpacing: '0.04em' }}>
                  {row.side === 'me' ? '我方' : '對方'}
                </span>
                <span style={{ fontSize: 11, color: TABI_COLORS.ink3, marginLeft: 1 }}>
                  {langInfo.flag} {langInfo.zh}
                </span>
                {isRecording && (
                  <span style={{ fontSize: 10.5, color: row.tone, marginLeft: 4, opacity: 0.9 }}>
                    聆聽中… (再點停止)
                  </span>
                )}
              </div>
              {/* input row */}
              <div style={{ padding: '3px 10px 8px', display: 'flex', gap: 6, alignItems: 'center' }}>
                <button
                  onClick={() => startRecording(row.side)}
                  disabled={busySomething}
                  style={{
                    width: 40, height: 40, borderRadius: 12, border: 'none', flexShrink: 0,
                    background: isRecording ? row.tone : `${row.tone}18`,
                    color: isRecording ? '#fff' : row.tone,
                    display: 'grid', placeItems: 'center',
                    boxShadow: isRecording ? `0 4px 14px ${row.tone}55` : 'none',
                    opacity: busySomething ? 0.35 : 1,
                    cursor: busySomething ? 'default' : 'pointer',
                    transition: 'all 0.18s',
                  }}
                >
                  {isRecording ? <PlayingBars /> : <MicIcon size={18} />}
                </button>
                <input
                  value={row.draft}
                  onChange={e => row.setDraft(e.target.value)}
                  onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendText(row.side)}
                  placeholder={row.placeholder}
                  disabled={isRecording || !!translating}
                  style={{
                    flex: 1, minWidth: 0, padding: '10px 12px', borderRadius: 12,
                    border: `1.5px solid ${isRecording ? row.tone : TABI_COLORS.line2}`,
                    background: TABI_COLORS.card, fontSize: 16, color: TABI_COLORS.ink,
                    outline: 'none', fontFamily: 'inherit', touchAction: 'manipulation',
                    transition: 'border-color 0.15s',
                  }}
                />
                <button
                  onClick={() => sendText(row.side)}
                  disabled={!canSend}
                  style={{
                    width: 40, height: 40, borderRadius: 12, border: 'none', flexShrink: 0,
                    background: canSend ? row.tone : TABI_COLORS.line2,
                    color: '#fff', display: 'grid', placeItems: 'center',
                    cursor: canSend ? 'pointer' : 'default',
                    transition: 'background 0.15s',
                  }}
                >
                  <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
                    <path d="M22 2L11 13"/><path d="M22 2L15 22l-4-9-9-4 20-7z"/>
                  </svg>
                </button>
              </div>
            </div>
          );
        })}
        {(transcribing || translating) && (
          <div style={{ padding: '2px 14px 6px', display: 'flex', alignItems: 'center', gap: 6 }}>
            <div style={{ width: 13, height: 13, borderRadius: 7, border: `2px solid ${TABI_COLORS.ink3}`, borderTopColor: 'transparent', animation: 'tabiSpin 0.8s linear infinite' }}/>
            <span style={{ fontSize: 11.5, color: TABI_COLORS.ink3 }}>
              {transcribing ? '辨識中…' : '翻譯中…'}
            </span>
          </div>
        )}
        {error && (
          <div style={{ padding: '2px 14px 6px', fontSize: 12, color: TABI_COLORS.red }}>
            {error}
          </div>
        )}
      </div>
    </div>
  );
}
function Bubble({ m, myLang, theirLang }) {
  const [bigCard, setBigCard] = React.useState(false);
  const [flipped, setFlipped] = React.useState(true);
  const [speaking, setSpeaking] = React.useState(false);
  const [ttsPending, setTtsPending] = React.useState(false);
  const me = m.from === 'me';
  const srcCode  = me ? myLang    : theirLang;
  const tgtCode  = me ? theirLang : myLang;
  const srcInfo  = VOICE_LANGS.find(l => l.code === srcCode) || VOICE_LANGS[0];
  const tgtInfo  = VOICE_LANGS.find(l => l.code === tgtCode) || VOICE_LANGS[1];

  const stopSpeak = () => {
    _stopSpeech();
    setSpeaking(false);
    setTtsPending(false);
  };

  const speak = (text, tts) => {
    _playSpeech(text, tts, {
      onGenerating: () => { setTtsPending(true); setSpeaking(false); },
      onStart: () => { setTtsPending(false); setSpeaking(true); },
      onEnd: () => { setTtsPending(false); setSpeaking(false); },
      onError: () => { setTtsPending(false); setSpeaking(false); },
    });
  };

  const trLen = (m.tr || '').length;
  const bigFontSize = trLen > 50 ? 26 : trLen > 30 ? 36 : trLen > 18 ? 46 : 58;

  return (
    <div style={{ marginTop: 14 }}>
      {bigCard && ReactDOM.createPortal(
        <div style={{
          position: 'fixed', inset: 0, zIndex: 9000,
          background: '#0d0d0d',
          display: 'flex', flexDirection: 'column',
          overflow: 'hidden',
        }}>
          {/* ── holder's controls (top, normal orientation) ── */}
          <div style={{
            display: 'flex', alignItems: 'center', justifyContent: 'space-between',
            padding: '14px 18px 10px',
          }}>
            {/* original text — for holder to confirm */}
            <div style={{ fontSize: 13, lineHeight: 1.5, color: 'rgba(255,255,255,0.35)', flex: 1, marginRight: 12 }}>
              {m.orig}
            </div>
            {/* close X */}
            <div onClick={() => setBigCard(false)} style={{
              width: 36, height: 36, borderRadius: 18, flexShrink: 0,
              background: 'rgba(255,255,255,0.1)',
              display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
            }}>
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.7)" strokeWidth="2.5">
                <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
              </svg>
            </div>
          </div>

          <div style={{ height: 1, background: 'rgba(255,255,255,0.07)', margin: '0 18px' }}/>

          {/* ── big translation — rotatable ── */}
          <div style={{
            flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
            padding: '24px 40px',
          }}>
            <div style={{
              fontFamily: tabiSerif, fontSize: bigFontSize, fontWeight: 700,
              lineHeight: 1.5, color: '#fff', textAlign: 'center',
              transform: flipped ? 'rotate(180deg)' : 'none',
              transition: 'transform 0.3s ease',
            }}>
              {m.tr}
            </div>
          </div>

          <div style={{ height: 1, background: 'rgba(255,255,255,0.07)', margin: '0 18px' }}/>

          <div style={{ padding: '0 18px' }}>
            <TTSInlineSettings lang={tgtInfo.tts} dark />
          </div>

          {/* ── holder's bottom controls (normal orientation) ── */}
          <div style={{
            display: 'flex', alignItems: 'center', gap: 10,
            padding: '12px 18px 28px',
          }}>
            <button onClick={(speaking || ttsPending) ? stopSpeak : () => speak(m.tr, tgtInfo.tts)} style={{
              display: 'inline-flex', alignItems: 'center', gap: 6,
              padding: '9px 18px', borderRadius: 22, border: 'none', cursor: 'pointer',
              background: (speaking || ttsPending) ? TABI_COLORS.red : 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.85)',
              fontSize: 13, fontWeight: 600,
            }}>
              {(speaking || ttsPending) ? <StopIcon size={11}/> : <PlayIcon size={11}/>} {tgtInfo.flag} {ttsPending ? '語音生成中' : speaking ? '停止' : '播放'}
            </button>
            <button onClick={() => setFlipped(f => !f)} style={{
              display: 'inline-flex', alignItems: 'center', gap: 6,
              padding: '9px 18px', borderRadius: 22, border: 'none', cursor: 'pointer',
              background: flipped ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)',
              color: 'rgba(255,255,255,0.85)', fontSize: 13, fontWeight: 600,
            }}>
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
                <polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/>
                <path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4-4.64 4.36A9 9 0 0 1 3.51 15"/>
              </svg>
              {flipped ? '翻轉回來' : '翻轉給對方'}
            </button>
            <div style={{ flex: 1 }}/>
            <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.25)', fontWeight: 600 }}>
              點 ✕ 關閉
            </div>
          </div>
        </div>,
        document.body
      )}

      <div style={{
        fontSize: 10, fontWeight: 700, letterSpacing: '0.1em',
        color: me ? TABI_COLORS.red : TABI_COLORS.blue,
        marginBottom: 5, textAlign: me ? 'right' : 'left',
      }}>
        {me ? '我' : '對方'} · {srcInfo.flag} {srcInfo.zh}
      </div>
      <div style={{
        borderRadius: 18, overflow: 'hidden',
        background: me ? TABI_COLORS.ink : TABI_COLORS.card,
        border: me ? 'none' : `1px solid ${TABI_COLORS.line}`,
      }}>
        {/* original text — muted */}
        <div style={{
          padding: '12px 16px 8px',
          fontSize: 14, lineHeight: 1.6,
          color: me ? 'rgba(255,255,255,0.5)' : TABI_COLORS.ink3,
        }}>
          {m.orig}
        </div>
        <div style={{ height: 1, background: me ? 'rgba(255,255,255,0.1)' : TABI_COLORS.line, margin: '0 16px' }}/>
        {/* translation — clickable to expand */}
        <div onClick={() => setBigCard(true)} style={{
          padding: '10px 16px 8px',
          fontFamily: tabiSerif, fontSize: 20, fontWeight: 600, lineHeight: 1.55,
          color: me ? '#fff' : TABI_COLORS.ink,
          cursor: 'pointer', position: 'relative',
        }}>
          {m.tr}
          {/* expand hint icon */}
          <span style={{
            position: 'absolute', top: 10, right: 16,
            opacity: 0.3,
          }}>
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
              <polyline points="15 3 21 3 21 9"/>
              <polyline points="9 21 3 21 3 15"/>
              <line x1="21" y1="3" x2="14" y2="10"/>
              <line x1="3" y1="21" x2="10" y2="14"/>
            </svg>
          </span>
        </div>
        <div style={{ padding: '0 16px 8px' }}>
          <TTSInlineSettings lang={tgtInfo.tts} dark={me} />
        </div>
        {/* footer: play button + expand button + lang label */}
        <div style={{
          padding: '4px 16px 13px', display: 'flex', alignItems: 'center', gap: 8,
        }}>
          <button onClick={(speaking || ttsPending) ? stopSpeak : () => speak(m.tr, tgtInfo.tts)} style={{
            display: 'inline-flex', alignItems: 'center', gap: 5,
            padding: '5px 12px', borderRadius: 20, border: 'none', cursor: 'pointer',
            background: (speaking || ttsPending) ? TABI_COLORS.red : (me ? 'rgba(255,255,255,0.12)' : `${TABI_COLORS.ink}08`),
            color: (speaking || ttsPending) ? '#fff' : (me ? 'rgba(255,255,255,0.85)' : TABI_COLORS.ink2),
            fontSize: 12, fontWeight: 600,
          }}>
            {(speaking || ttsPending) ? <StopIcon size={10}/> : <PlayIcon size={10}/>} {ttsPending ? '生成中' : speaking ? '停止' : `播放 ${tgtInfo.flag}`}
          </button>
          <button onClick={() => setBigCard(true)} style={{
            display: 'inline-flex', alignItems: 'center', gap: 5,
            padding: '5px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
            background: me ? 'rgba(255,255,255,0.12)' : `${TABI_COLORS.ink}08`,
            color: me ? 'rgba(255,255,255,0.85)' : TABI_COLORS.ink2,
            fontSize: 12, fontWeight: 600,
          }}>
            <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
              <polyline points="15 3 21 3 21 9"/>
              <polyline points="9 21 3 21 3 15"/>
              <line x1="21" y1="3" x2="14" y2="10"/>
              <line x1="3" y1="21" x2="10" y2="14"/>
            </svg>
            給對方看
          </button>
          <div style={{ flex: 1 }}/>
          <div style={{
            fontSize: 10, color: me ? 'rgba(255,255,255,0.35)' : TABI_COLORS.ink3,
            fontWeight: 600, letterSpacing: '0.05em',
          }}>{tgtInfo.zh} · {tgtCode}</div>
        </div>
      </div>
    </div>
  );
}
// ─────────── VOICE HISTORY ───────────
function VoiceHistoryScreen({ go, onOpen }) {
  const [list, setList]         = React.useState(null);
  const [confirmDel, setConfirmDel] = React.useState(null);
  const [deleting, setDeleting]     = React.useState(false);

  const load = () => {
    fetch('/api/voice/conversations', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
      .then(r => r.json()).then(d => setList(Array.isArray(d) ? d : [])).catch(() => setList([]));
  };
  React.useEffect(() => { load(); }, []);

  const remove = async (id) => {
    setDeleting(true);
    await fetch(`/api/voice/conversations/${id}`, {
      method: 'DELETE', headers: { Authorization: `Bearer ${window.tabiToken()}` },
    }).catch(() => {});
    setDeleting(false);
    setConfirmDel(null);
    load();
  };

  const LANG_FLAG = { ZH:'🇹🇼', JA:'🇯🇵', EN:'🇬🇧', KO:'🇰🇷', TH:'🇹🇭', VI:'🇻🇳', FR:'🇫🇷', ES:'🇪🇸', IT:'🇮🇹', DE:'🇩🇪' };

  return (
    <div style={{ background: TABI_COLORS.bg, minHeight: '100%', position: 'relative' }}>
      <TabiTopBar title="對話紀錄" subtitle="HISTORY · 会話履歴" onBack={() => go('voice')} />
      <div style={{ padding: '14px 16px 30px' }}>
        {list === null ? (
          <div style={{ display: 'flex', justifyContent: 'center', padding: '60px 0' }}>
            <div style={{ width: 26, height: 26, borderRadius: 13, border: `2px solid ${TABI_COLORS.line2}`, borderTopColor: TABI_COLORS.red, animation: 'tabiSpin 0.8s linear infinite' }}/>
          </div>
        ) : list.length === 0 ? (
          <div style={{ textAlign: 'center', padding: '60px 20px', color: TABI_COLORS.ink3 }}>
            <div style={{ fontSize: 36, marginBottom: 8 }}>💬</div>
            <div style={{ fontSize: 13 }}>還沒有儲存的對話</div>
            <button onClick={() => go('voice')} style={{
              marginTop: 16, padding: '10px 18px', borderRadius: 12, border: 'none',
              background: TABI_COLORS.red, color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
            }}>開始對話</button>
          </div>
        ) : list.map(c => (
          <div key={c.id} style={{
            background: TABI_COLORS.card, borderRadius: 16, padding: '13px 14px',
            marginTop: 10, display: 'flex', gap: 12, alignItems: 'center',
            border: `1px solid ${TABI_COLORS.line}`,
          }}>
            <div onClick={() => onOpen(c.id)} style={{
              flex: 1, minWidth: 0, cursor: 'pointer',
            }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 4 }}>
                <span style={{ fontSize: 13 }}>{LANG_FLAG[c.source_lang] || '🌐'}</span>
                <span style={{ fontSize: 9.5, fontWeight: 700, color: TABI_COLORS.ink3, letterSpacing: '0.05em' }}>
                  {c.source_lang} → {c.target_lang}
                </span>
                <span style={{ fontSize: 13 }}>{LANG_FLAG[c.target_lang] || '🌐'}</span>
              </div>
              <div style={{ fontFamily: tabiSerif, fontSize: 14.5, fontWeight: 600, color: TABI_COLORS.ink }}>
                {c.title || '未命名對話'}
              </div>
              <div style={{ fontSize: 11, color: TABI_COLORS.ink3, marginTop: 2 }}>
                {_fmtDate(c.updated_at)}
              </div>
            </div>
            <button onClick={() => setConfirmDel(c.id)} style={{
              width: 32, height: 32, borderRadius: 16, border: 'none', cursor: 'pointer',
              background: 'transparent', color: TABI_COLORS.ink3, display: 'grid', placeItems: 'center',
            }}>
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                <path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M6 6l1 14a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-14M10 11v6M14 11v6"/>
              </svg>
            </button>
          </div>
        ))}
      </div>
      {confirmDel && (
        <div onClick={() => setConfirmDel(null)} style={{
          position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.4)',
          zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
        }}>
          <div onClick={e => e.stopPropagation()} style={{
            background: TABI_COLORS.bg, borderRadius: 18, padding: '22px', width: '100%', maxWidth: 280,
          }}>
            <div style={{ fontFamily: tabiSerif, fontSize: 16, fontWeight: 600 }}>刪除這段對話？</div>
            <div style={{ fontSize: 12, color: TABI_COLORS.ink3, marginTop: 6, lineHeight: 1.5 }}>刪除後將無法復原。</div>
            <div style={{ display: 'flex', gap: 8, marginTop: 18 }}>
              <button onClick={() => setConfirmDel(null)} style={{
                flex: 1, padding: '11px', borderRadius: 12, border: `1px solid ${TABI_COLORS.line2}`,
                background: 'transparent', color: TABI_COLORS.ink, fontSize: 13, fontWeight: 600, cursor: 'pointer',
              }}>取消</button>
              <button onClick={() => remove(confirmDel)} disabled={deleting} style={{
                flex: 1, padding: '11px', borderRadius: 12, border: 'none',
                background: TABI_COLORS.red, color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
              }}>{deleting ? '刪除中…' : '刪除'}</button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

function VoiceDetailScreen({ go, convId }) {
  const [conv, setConv] = React.useState(null);

  React.useEffect(() => {
    if (!convId) return;
    fetch(`/api/voice/conversations/${convId}`, {
      headers: { Authorization: `Bearer ${window.tabiToken()}` },
    }).then(r => r.json()).then(setConv).catch(() => {});
  }, [convId]);

  if (!conv) return (
    <div style={{ background: TABI_COLORS.bg, minHeight: '100%' }}>
      <TabiTopBar title="對話詳情" onBack={() => go('voiceHistory')} />
      <div style={{ display: 'flex', justifyContent: 'center', padding: '60px 0' }}>
        <div style={{ width: 26, height: 26, borderRadius: 13, border: `2px solid ${TABI_COLORS.line2}`, borderTopColor: TABI_COLORS.red, animation: 'tabiSpin 0.8s linear infinite' }}/>
      </div>
    </div>
  );

  return (
    <div style={{ background: TABI_COLORS.bg, minHeight: '100%' }}>
      <TabiTopBar
        title={conv.title || '對話詳情'}
        subtitle={`${conv.source_lang} → ${conv.target_lang} · ${conv.messages.length} 則`}
        onBack={() => go('voiceHistory')}
      />
      <div style={{ padding: '10px 14px 40px' }}>
        {conv.messages.map(m => (
          <Bubble
            key={m.id}
            m={{ from: m.direction === 'outbound' ? 'me' : 'them', orig: m.original_text, tr: m.translated_text }}
            myLang={conv.source_lang}
            theirLang={conv.target_lang}
          />
        ))}
      </div>
    </div>
  );
}

const tinyBtn = {
  fontSize: 10.5, padding: '4px 8px', borderRadius: 999,
  border: `1px solid ${TABI_COLORS.line}`, background: 'transparent',
  color: TABI_COLORS.ink3, cursor: 'pointer', display: 'inline-flex',
  alignItems: 'center', gap: 4,
};
function SpeakBtn({ label, subtitle, tone, active, disabled, onClick }) {
  return (
    <button onClick={onClick} disabled={disabled && !active} style={{
      flex: 1, padding: '12px 14px', borderRadius: 16, border: 'none',
      cursor: disabled && !active ? 'default' : 'pointer',
      background: active ? tone : TABI_COLORS.card,
      color: active ? '#fff' : TABI_COLORS.ink,
      border: active ? 'none' : `1px solid ${TABI_COLORS.line2}`,
      display: 'flex', alignItems: 'center', gap: 10,
      transition: 'all 0.2s',
      boxShadow: active ? `0 8px 22px ${tone}66` : 'none',
      opacity: disabled && !active ? 0.45 : 1,
    }}>
      <div style={{
        width: 32, height: 32, borderRadius: 16,
        background: active ? 'rgba(255,255,255,0.22)' : `${tone}14`,
        color: active ? '#fff' : tone,
        display: 'grid', placeItems: 'center',
      }}>
        <MicIcon size={16} active={active} />
      </div>
      <div style={{ textAlign: 'left' }}>
        <div style={{ fontSize: 13, fontWeight: 600 }}>{label}</div>
        <div style={{ fontSize: 10, opacity: active ? 0.85 : 0.5 }}>{subtitle}</div>
      </div>
    </button>
  );
}

// ─────────── RECEIPT RESULT ───────────
function ReceiptResultScreen({ go }) {
  const items = [
    { jp: 'おにぎり 鮭',          zh: '飯糰 · 鮭魚', price: 180, qty: 2 },
    { jp: 'からあげ棒',           zh: '炸雞棒',     price: 168, qty: 1 },
    { jp: 'ペプシ 500ml',         zh: '百事可樂 500ml', price: 158, qty: 1 },
    { jp: 'ホットコーヒー L',     zh: '熱咖啡 L',  price: 200, qty: 1 },
  ];
  const subtotal = items.reduce((a, b) => a + b.price * b.qty, 0);
  const tax = Math.round(subtotal * 0.1);
  const total = subtotal + tax;
  return (
    <div style={{ background: TABI_COLORS.bg, minHeight: '100%' }}>
      <TabiTopBar
        title="發票整理"
        subtitle="RECEIPT · レシート"
        accent={TABI_COLORS.green}
        onBack={() => go('home')}
        right={<button style={{
          padding: '6px 10px', borderRadius: 10, border: 'none',
          background: `${TABI_COLORS.green}18`, color: TABI_COLORS.green,
          fontSize: 11, fontWeight: 600, cursor: 'pointer',
        }}>匯出</button>}
      />

      <div style={{ padding: '14px 16px 30px' }}>
        {/* receipt summary */}
        <div style={{
          background: TABI_COLORS.card, borderRadius: 18, padding: '18px',
          border: `1px solid ${TABI_COLORS.line}`,
        }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
            <div style={{
              width: 44, height: 44, borderRadius: 12,
              background: `${TABI_COLORS.green}18`, color: TABI_COLORS.green,
              display: 'grid', placeItems: 'center',
            }}><ReceiptIcon size={22} active /></div>
            <div style={{ flex: 1 }}>
              <div style={{ fontFamily: tabiSerif, fontSize: 17, fontWeight: 600, color: TABI_COLORS.ink }}>
                Lawson 新宿東口店
              </div>
              <div style={{ fontSize: 11.5, color: TABI_COLORS.ink3, marginTop: 2 }}>
                2026/05/06 · 14:02 · 東京都
              </div>
            </div>
          </div>

          {/* totals */}
          <div style={{
            marginTop: 16, padding: '14px', borderRadius: 14,
            background: TABI_COLORS.bg2, display: 'flex', alignItems: 'baseline', gap: 14,
          }}>
            <div style={{ flex: 1 }}>
              <div style={{ fontSize: 11, color: TABI_COLORS.ink3, fontWeight: 600, letterSpacing: '0.1em' }}>合計 TOTAL</div>
              <div style={{ fontFamily: tabiSerif, fontSize: 28, fontWeight: 700, color: TABI_COLORS.ink, marginTop: 2 }}>
                ¥{total.toLocaleString()}
              </div>
            </div>
            <div style={{ textAlign: 'right' }}>
              <div style={{ fontSize: 11, color: TABI_COLORS.ink3 }}>≈ 新台幣</div>
              <div style={{ fontFamily: tabiSerif, fontSize: 18, color: TABI_COLORS.green, fontWeight: 600 }}>
                NT${Math.round(total * 0.21)}
              </div>
            </div>
          </div>

          {/* breakdown */}
          <div style={{ marginTop: 14 }}>
            {items.map((it, i) => (
              <div key={i} style={{
                display: 'flex', alignItems: 'center', gap: 10,
                padding: '10px 0', borderTop: i ? `1px dashed ${TABI_COLORS.line2}` : 'none',
              }}>
                <span style={{
                  fontSize: 11, fontWeight: 600, color: TABI_COLORS.ink3, minWidth: 22,
                }}>×{it.qty}</span>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ fontFamily: tabiSerif, fontSize: 13.5, color: TABI_COLORS.ink, fontWeight: 500 }}>{it.zh}</div>
                  <div style={{ fontSize: 10.5, color: TABI_COLORS.ink3 }}>{it.jp}</div>
                </div>
                <div style={{ fontSize: 13, color: TABI_COLORS.ink, fontWeight: 600 }}>
                  ¥{(it.price * it.qty).toLocaleString()}
                </div>
              </div>
            ))}
          </div>

          <div style={{ marginTop: 14, padding: '10px 0', borderTop: `1px solid ${TABI_COLORS.line}` }}>
            {[
              ['小計 / 不含稅', `¥${subtotal.toLocaleString()}`],
              ['消費税 10%',   `¥${tax.toLocaleString()}`],
            ].map(([k, v], i) => (
              <div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: TABI_COLORS.ink2, padding: '3px 0' }}>
                <span>{k}</span><span>{v}</span>
              </div>
            ))}
          </div>
        </div>

        {/* trip totals card */}
        <div style={{ marginTop: 16 }}>
          <SectionLabel jp="この旅" zh="本次旅行支出" />
          <div style={{
            background: TABI_COLORS.card, borderRadius: 16, padding: '14px',
            marginTop: 10, border: `1px solid ${TABI_COLORS.line}`,
            display: 'flex', alignItems: 'center', gap: 14,
          }}>
            <div style={{ flex: 1 }}>
              <div style={{ fontSize: 11, color: TABI_COLORS.ink3 }}>累積 12 張發票</div>
              <div style={{ fontFamily: tabiSerif, fontSize: 22, fontWeight: 700, color: TABI_COLORS.ink, marginTop: 2 }}>
                ¥48,520 <span style={{ fontSize: 12, color: TABI_COLORS.ink3, fontWeight: 400 }}>≈ NT$10,189</span>
              </div>
            </div>
            <button style={{
              padding: '10px 14px', borderRadius: 12, border: 'none', cursor: 'pointer',
              background: TABI_COLORS.ink, color: '#fff', fontSize: 12, fontWeight: 600,
            }}>看圖表 ›</button>
          </div>
          {/* category bar */}
          <div style={{
            marginTop: 10, height: 8, borderRadius: 4, overflow: 'hidden', display: 'flex',
          }}>
            <div style={{ width: '38%', background: TABI_COLORS.red }}/>
            <div style={{ width: '24%', background: TABI_COLORS.green }}/>
            <div style={{ width: '20%', background: TABI_COLORS.blue }}/>
            <div style={{ width: '18%', background: TABI_COLORS.warn }}/>
          </div>
          <div style={{ display: 'flex', gap: 14, marginTop: 8, fontSize: 10.5, color: TABI_COLORS.ink3, flexWrap: 'wrap' }}>
            {[
              ['餐飲 38%', TABI_COLORS.red],
              ['便利店 24%', TABI_COLORS.green],
              ['交通 20%', TABI_COLORS.blue],
              ['購物 18%', TABI_COLORS.warn],
            ].map(([t, c]) => (
              <span key={t} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
                <span style={{ width: 6, height: 6, borderRadius: 3, background: c }}/>{t}
              </span>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

// ─────────── SIGN RESULT (拍照翻譯) ───────────
function SignResultScreen({ go, signData }) {
  const [playing, setPlaying] = React.useState(false);
  const [ttsPending, setTtsPending] = React.useState(false);
  const [speakIdx, setSpeakIdx] = React.useState(null); // global segment index
  const [pendingIdx, setPendingIdx] = React.useState(null);

  // Normalise: support multi-photo {photos:[...]}, single-photo {segments,summary,note}, legacy {original,translation}
  const photos = React.useMemo(() => {
    if (signData?.photos?.length) return signData.photos;
    if (signData?.segments) return [{ segments: signData.segments, summary: signData.summary, note: signData.note, image_url: signData.image_url }];
    if (signData?.original) {
      const origs = signData.original.split(/\s*\/\s*/).map(s => s.trim()).filter(Boolean);
      const trans  = (signData.translation || '').split(/\s*\/\s*/).map(s => s.trim());
      return [{ segments: origs.map((orig, i) => ({ orig, tran: trans[i] || '' })), image_url: null }];
    }
    return [];
  }, [signData]);

  const allSegments = React.useMemo(() => photos.flatMap(p => p.segments || []), [photos]);
  const multiPhoto  = photos.length > 1;

  // Compute global index offset for each photo
  const photoOffset = (photoIdx) => photos.slice(0, photoIdx).reduce((s, p) => s + (p.segments || []).length, 0);

  function stopSpeech() {
    _stopSpeech();
    setPlaying(false);
    setTtsPending(false);
    setSpeakIdx(null);
    setPendingIdx(null);
  }

  function speakAll() {
    const text = allSegments.map(s => s.orig).join('。');
    if (!text) return;
    setSpeakIdx(null);
    _playSpeech(text, 'ja-JP', {
      rate: 0.9,
      onGenerating: () => { setTtsPending(true); setPlaying(false); },
      onStart: () => { setTtsPending(false); setPlaying(true); },
      onEnd: () => { setTtsPending(false); setPlaying(false); },
      onError: () => { setTtsPending(false); setPlaying(false); },
    });
  }

  function speakOne(globalIdx, text) {
    if (speakIdx === globalIdx || pendingIdx === globalIdx) { stopSpeech(); return; }
    if (!text) return;
    setPlaying(false);
    _playSpeech(text, 'ja-JP', {
      rate: 0.9,
      onGenerating: () => { setPendingIdx(globalIdx); setSpeakIdx(null); },
      onStart: () => { setPendingIdx(null); setSpeakIdx(globalIdx); },
      onEnd: () => { setPendingIdx(null); setSpeakIdx(null); },
      onError: () => { setPendingIdx(null); setSpeakIdx(null); },
    });
  }

  const origFontSize = (text) => {
    if (!text) return 20;
    if (text.length <= 10) return 22;
    if (text.length <= 20) return 18;
    if (text.length <= 35) return 16;
    return 14;
  };

  if (!signData) return (
    <div style={{ background: TABI_COLORS.bg, height: '100%', display: 'flex', flexDirection: 'column' }}>
      <TabiTopBar title="拍照翻譯" subtitle="PHOTO · 写真翻訳" accent={TABI_COLORS.warn} onBack={() => go('home')} />
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16, padding: 32 }}>
        <div style={{ fontSize: 44 }}>📷</div>
        <div style={{ fontSize: 14, color: TABI_COLORS.ink3, textAlign: 'center', lineHeight: 1.7 }}>
          拍攝路標、看板、標示等任意文字<br/>AI 即時翻譯成中文
        </div>
        <button onClick={() => go('sign')} style={{
          padding: '14px 28px', borderRadius: 16, border: 'none',
          background: TABI_COLORS.ink, color: '#fff', fontSize: 15, fontWeight: 600, cursor: 'pointer',
        }}>立即拍照</button>
      </div>
    </div>
  );

  // Overall overview for the top card
  const overviewText = multiPhoto
    ? `共 ${photos.length} 張照片，合計 ${allSegments.length} 段文字`
    : (photos[0]?.summary || photos[0]?.note || `辨識到 ${allSegments.length} 段文字`);

  // Render one translation card (used per-photo in multi, or once in single)
  function SegmentCard({ segs, globalOffset }) {
    return (
      <div style={{ background: TABI_COLORS.card, borderRadius: 18, overflow: 'hidden', boxShadow: '0 2px 14px rgba(26,22,18,0.07)' }}>
        <div style={{
          padding: '10px 16px 9px', background: TABI_COLORS.ink,
          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        }}>
          <div style={{ fontSize: 11, fontWeight: 700, color: 'rgba(255,255,255,0.5)', letterSpacing: '0.09em' }}>
            {segs.length} 段文字
          </div>
          {!multiPhoto && (
            <button onClick={(playing || ttsPending) ? stopSpeech : speakAll} style={{
              padding: '4px 11px', borderRadius: 9, border: 'none',
              background: TABI_COLORS.red,
              color: '#fff', fontSize: 11.5, fontWeight: 600, cursor: 'pointer',
              display: 'inline-flex', alignItems: 'center', gap: 4,
            }}>
              {(playing || ttsPending) ? <StopIcon size={10} /> : <PlayIcon size={10} />}
              {ttsPending ? '語音生成中' : playing ? '停止' : '全部朗讀'}
            </button>
          )}
        </div>
        {segs.map((seg, i) => {
          const gi = globalOffset + i;
          return (
            <div key={i} style={{
              padding: '13px 14px',
              borderTop: i > 0 ? `1px solid ${TABI_COLORS.line}` : 'none',
              display: 'flex', alignItems: 'flex-start', gap: 10,
            }}>
              <div style={{
                flex: 1, minWidth: 0,
                maxHeight: 120, overflowY: 'auto',
                scrollbarWidth: 'none', WebkitOverflowScrolling: 'touch',
              }}>
                <div style={{
                  fontFamily: tabiSerif, fontSize: origFontSize(seg.orig),
                  fontWeight: 600, color: TABI_COLORS.ink,
                  lineHeight: 1.4, letterSpacing: '0.02em',
                  marginBottom: seg.tran ? 5 : 0,
                }}>{seg.orig}</div>
                {seg.tran && (
                  <div style={{ fontSize: seg.tran.length > 35 ? 13 : 14, color: TABI_COLORS.ink2, lineHeight: 1.6 }}>
                    {seg.tran}
                  </div>
                )}
              </div>
              <button onClick={() => speakOne(gi, seg.orig)} style={{
                width: 34, height: 34, borderRadius: 17, border: 'none', flexShrink: 0, marginTop: 1,
                background: (speakIdx === gi || pendingIdx === gi) ? TABI_COLORS.red : TABI_COLORS.bg2,
                color: (speakIdx === gi || pendingIdx === gi) ? '#fff' : TABI_COLORS.ink3,
                display: 'grid', placeItems: 'center', cursor: 'pointer',
                transition: 'background 0.15s',
              }}>{(speakIdx === gi || pendingIdx === gi) ? <StopIcon size={12} /> : <PlayIcon size={12} />}</button>
            </div>
          );
        })}
      </div>
    );
  }

  return (
    <div style={{ background: TABI_COLORS.bg, minHeight: '100%', display: 'flex', flexDirection: 'column' }}>
      <TabiTopBar
        title="拍照翻譯"
        subtitle="PHOTO · 写真翻訳"
        accent={TABI_COLORS.warn}
        onBack={() => go('home')}
        right={
          <button onClick={() => go('sign')} style={{
            padding: '7px 12px', borderRadius: 12, border: 'none', cursor: 'pointer',
            background: TABI_COLORS.ink, color: '#fff', fontSize: 11.5, fontWeight: 600,
            display: 'inline-flex', alignItems: 'center', gap: 5,
          }}>
            <CameraIcon size={13} active /> 再拍
          </button>
        }
      />

      <div style={{ flex: 1, overflowY: 'auto', padding: '14px 16px 36px' }}>

        {/* ① Overview card */}
        <div style={{
          background: TABI_COLORS.card, borderRadius: 16, padding: '13px 15px',
          marginBottom: 14, boxShadow: '0 2px 12px rgba(26,22,18,0.06)',
          display: 'flex', gap: 11, alignItems: 'flex-start',
        }}>
          <div style={{
            width: 36, height: 36, borderRadius: 11, flexShrink: 0,
            background: `${TABI_COLORS.warn}18`, display: 'grid', placeItems: 'center', fontSize: 17,
          }}>📋</div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 10.5, fontWeight: 700, color: TABI_COLORS.warn, letterSpacing: '0.1em', marginBottom: 4 }}>AI 概覽</div>
            <div style={{ fontSize: 13.5, color: TABI_COLORS.ink, lineHeight: 1.65 }}>{overviewText}</div>
          </div>
          {multiPhoto && (
            <button onClick={(playing || ttsPending) ? stopSpeech : speakAll} style={{
              padding: '6px 12px', borderRadius: 10, border: 'none', flexShrink: 0,
              background: TABI_COLORS.red,
              color: '#fff', fontSize: 12, fontWeight: 600, cursor: 'pointer',
              display: 'inline-flex', alignItems: 'center', gap: 4, alignSelf: 'center',
            }}>
              {(playing || ttsPending) ? <StopIcon size={10} /> : <PlayIcon size={10} />}
              {ttsPending ? '語音生成中' : playing ? '停止' : '全部朗讀'}
            </button>
          )}
        </div>

        <TTSInlineSettings lang="ja-JP" />

        {/* ② Photo sections */}
        {photos.map((photo, pi) => (
          <div key={pi} style={{ marginBottom: 14 }}>

            {/* Per-photo header when multiple */}
            {multiPhoto && (
              <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
                <div style={{
                  fontSize: 11.5, fontWeight: 700, color: TABI_COLORS.ink2,
                  background: TABI_COLORS.bg2, borderRadius: 8, padding: '3px 10px', flexShrink: 0,
                }}>第 {pi + 1} 張</div>
                {photo.summary && (
                  <>
                    <div style={{ flex: 1, height: 1, background: TABI_COLORS.line }} />
                    <div style={{ fontSize: 11.5, color: TABI_COLORS.ink3, flexShrink: 0, maxWidth: '60%',
                      overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                      {photo.summary}
                    </div>
                  </>
                )}
              </div>
            )}

            {/* Photo thumbnail — full image, no crop */}
            {photo.image_url && (
              <div style={{
                marginBottom: 10, borderRadius: 14, overflow: 'hidden',
                background: '#111', boxShadow: '0 3px 16px rgba(26,22,18,0.12)',
              }}>
                <img
                  src={photo.image_url}
                  alt={`照片 ${pi + 1}`}
                  style={{ width: '100%', display: 'block', objectFit: 'contain', maxHeight: multiPhoto ? 180 : 260 }}
                />
              </div>
            )}

            {/* Note for this photo */}
            {photo.note && (
              <div style={{
                marginBottom: 8, padding: '8px 12px', borderRadius: 10,
                background: `${TABI_COLORS.warn}10`, border: `1px solid ${TABI_COLORS.warn}25`,
                fontSize: 12.5, color: TABI_COLORS.ink2, lineHeight: 1.6,
                display: 'flex', gap: 6,
              }}>
                <span>💡</span><span>{photo.note}</span>
              </div>
            )}

            {/* Translation card for this photo */}
            <SegmentCard segs={photo.segments || []} globalOffset={photoOffset(pi)} />
          </div>
        ))}

        {/* ③ Action buttons */}
        <div style={{ display: 'flex', gap: 10, marginTop: 4 }}>
          <button onClick={() => go('signChat')} style={{
            flex: 1, padding: '14px', borderRadius: 16, border: 'none',
            background: TABI_COLORS.ink, color: '#fff', fontSize: 14, fontWeight: 600,
            cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
          }}>
            💬 繼續對話
          </button>
          <button onClick={() => go('sign')} style={{
            padding: '14px 18px', borderRadius: 16,
            border: `1px solid ${TABI_COLORS.line2}`, background: 'transparent',
            color: TABI_COLORS.ink, fontSize: 14, fontWeight: 600, cursor: 'pointer',
            display: 'flex', alignItems: 'center', gap: 6,
          }}>
            <CameraIcon size={15} /> 再拍
          </button>
        </div>

      </div>
    </div>
  );
}


// ─────────── SIGN CHAT SCREEN ────────────────────────────────

function SignChatScreen({ go, signData }) {
  const [messages, setMessages] = React.useState([]);
  const [input, setInput]       = React.useState('');
  const [loading, setLoading]   = React.useState(false);
  const bottomRef = React.useRef();

  React.useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages, loading]);

  async function send() {
    const text = input.trim();
    if (!text || loading) return;
    setInput('');
    const next = [...messages, { role: 'user', content: text }];
    setMessages(next);
    setLoading(true);
    try {
      const r = await fetch('/api/scan/sign-chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() },
        body: JSON.stringify({
          segments: signData?.segments || [],
          summary:  signData?.summary  || '',
          note:     signData?.note     || '',
          history:  messages,
          message:  text,
        }),
      });
      if (!r.ok) throw new Error('請求失敗');
      const data = await r.json();
      setMessages(prev => [...prev, { role: 'assistant', content: data.reply }]);
    } catch {
      setMessages(prev => [...prev, { role: 'assistant', content: '抱歉，發生錯誤，請再試一次。' }]);
    } finally {
      setLoading(false);
    }
  }

  const summary = signData?.summary || signData?.note || '';

  return (
    <div style={{ background: TABI_COLORS.bg, height: '100%', display: 'flex', flexDirection: 'column' }}>
      <TabiTopBar title="深入詢問" subtitle="AI · 翻譯對話" accent={TABI_COLORS.warn} onBack={() => go('signResult')} />

      {/* Context chip */}
      {summary ? (
        <div style={{ padding: '8px 16px 0' }}>
          <div style={{
            background: `${TABI_COLORS.warn}14`, borderRadius: 10, padding: '8px 13px',
            fontSize: 12.5, color: TABI_COLORS.ink2, lineHeight: 1.5,
            display: 'flex', gap: 6, alignItems: 'flex-start',
          }}>
            <span style={{ flexShrink: 0 }}>📋</span>
            <span>{summary}</span>
          </div>
        </div>
      ) : null}

      {/* Messages */}
      <div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 8px', display: 'flex', flexDirection: 'column', gap: 10 }}>
        {messages.length === 0 && !loading && (
          <div style={{ textAlign: 'center', color: TABI_COLORS.ink3, padding: '40px 16px' }}>
            <div style={{ fontSize: 36, marginBottom: 12 }}>💬</div>
            <div style={{ fontSize: 13.5, lineHeight: 1.8 }}>
              可以問我關於這張照片的任何問題<br/>
              <span style={{ fontSize: 12, opacity: 0.7 }}>例：這裡是什麼地方？這個標示什麼意思？</span>
            </div>
          </div>
        )}

        {messages.map((m, i) => (
          <div key={i} style={{ display: 'flex', justifyContent: m.role === 'user' ? 'flex-end' : 'flex-start' }}>
            <div style={{
              maxWidth: '82%', padding: '10px 14px', lineHeight: 1.65, fontSize: 14,
              borderRadius: m.role === 'user' ? '18px 18px 5px 18px' : '18px 18px 18px 5px',
              background: m.role === 'user' ? TABI_COLORS.ink : TABI_COLORS.card,
              color: m.role === 'user' ? '#fff' : TABI_COLORS.ink,
              boxShadow: '0 1px 8px rgba(26,22,18,0.07)',
            }}>{m.content}</div>
          </div>
        ))}

        {loading && (
          <div style={{ display: 'flex', justifyContent: 'flex-start' }}>
            <div style={{
              padding: '10px 16px', borderRadius: '18px 18px 18px 5px',
              background: TABI_COLORS.card, boxShadow: '0 1px 8px rgba(26,22,18,0.07)',
              display: 'flex', gap: 4, alignItems: 'center',
            }}>
              {[0,1,2].map(j => (
                <div key={j} style={{
                  width: 6, height: 6, borderRadius: 3,
                  background: TABI_COLORS.ink3, opacity: 0.5,
                  animation: `tabiBar 1s ease-in-out ${j * 0.18}s infinite`,
                }} />
              ))}
            </div>
          </div>
        )}

        <div ref={bottomRef} />
      </div>

      {/* Input bar */}
      <div style={{
        padding: `10px 16px calc(env(safe-area-inset-bottom, 0px) + 10px)`,
        borderTop: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg,
        display: 'flex', gap: 8, alignItems: 'center',
      }}>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
          placeholder="問關於這張照片的問題…"
          style={{
            flex: 1, padding: '11px 14px', borderRadius: 14,
            border: `1.5px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg,
            fontSize: 16, color: TABI_COLORS.ink, outline: 'none', fontFamily: 'inherit',
          }}
        />
        <button onClick={send} disabled={!input.trim() || loading} style={{
          width: 44, height: 44, borderRadius: 22, border: 'none', flexShrink: 0,
          background: input.trim() && !loading ? TABI_COLORS.red : TABI_COLORS.line2,
          color: '#fff', fontSize: 22, fontWeight: 300, lineHeight: 1,
          display: 'grid', placeItems: 'center', cursor: input.trim() && !loading ? 'pointer' : 'default',
          transition: 'background 0.15s',
        }}>›</button>
      </div>
    </div>
  );
}

// ─────────── PHRASES SCREEN ─────────────────────────────────
const PHRASE_CATS = ['全部', '禮貌', '溝通', '問路', '交通', '住宿', '餐廳', '購物', '便利商店', '藥妝', '緊急'];

function PhrasesScreen({ go }) {
  const [phrases, setPhrases] = React.useState([]);
  const [activeCat, setActiveCat] = React.useState('全部');
  const [speaking, setSpeaking] = React.useState(null);
  const [ttsPending, setTtsPending] = React.useState(null);
  const [search, setSearch] = React.useState('');

  React.useEffect(() => {
    fetch('/api/phrases', { headers: { Authorization: `Bearer ${window.tabiToken()}` } })
      .then(r => r.ok ? r.json() : [])
      .then(d => setPhrases(Array.isArray(d) ? d : []))
      .catch(() => {});
  }, []);

  function handleSpeak(id, text) {
    if (speaking === id || ttsPending === id) {
      _stopSpeech();
      setSpeaking(null);
      setTtsPending(null);
      return;
    }
    _speakJP(text, {
      onGenerating: () => { setTtsPending(id); setSpeaking(null); },
      onStart: () => { setTtsPending(null); setSpeaking(id); },
      onEnd: () => { setTtsPending(null); setSpeaking(s => s === id ? null : s); },
      onError: () => { setTtsPending(null); setSpeaking(s => s === id ? null : s); },
    });
  }

  const q = search.trim().toLowerCase();
  const visible = phrases.filter(p => {
    const catOk = activeCat === '全部' || p.category === activeCat;
    const searchOk = !q || p.japanese.toLowerCase().includes(q) || p.chinese.includes(q);
    return catOk && searchOk;
  });

  // Derive used categories from actual data (keep canonical order)
  const usedCats = PHRASE_CATS.filter(c => c === '全部' || phrases.some(p => p.category === c));

  return (
    <div style={{ background: TABI_COLORS.bg, minHeight: '100%', paddingBottom: 32, display: 'flex', flexDirection: 'column' }}>
      <TabiTopBar title="旅遊常用句" subtitle="よく使う日本語" onBack={() => go('home')} />

      {/* Search bar */}
      <div style={{ padding: '12px 16px 0' }}>
        <div style={{
          background: TABI_COLORS.card, borderRadius: 12, border: `1px solid ${TABI_COLORS.line}`,
          display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px',
        }}>
          <svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke={TABI_COLORS.ink3} strokeWidth="1.8" strokeLinecap="round">
            <circle cx="6.5" cy="6.5" r="4.5"/><path d="m10.5 10.5 3 3"/>
          </svg>
          <input
            value={search}
            onChange={e => setSearch(e.target.value)}
            placeholder="搜尋句子…"
            style={{
              flex: 1, border: 'none', outline: 'none', padding: '11px 0',
              background: 'transparent', fontSize: 14, color: TABI_COLORS.ink,
              fontFamily: tabiFont,
            }}
          />
          {search && (
            <button onClick={() => setSearch('')} style={{
              border: 'none', background: 'none', color: TABI_COLORS.ink3,
              fontSize: 16, cursor: 'pointer', padding: '0 2px', lineHeight: 1,
            }}>×</button>
          )}
        </div>
      </div>

      {/* Category tabs */}
      <div style={{ padding: '10px 16px 0', display: 'flex', gap: 6, overflowX: 'auto', flexShrink: 0 }}>
        {usedCats.map(cat => {
          const on = activeCat === cat;
          return (
            <button key={cat} onClick={() => setActiveCat(cat)} style={{
              padding: '7px 14px', borderRadius: 20, border: 'none', cursor: 'pointer',
              whiteSpace: 'nowrap', flexShrink: 0, fontSize: 12.5, fontWeight: 600,
              background: on ? TABI_COLORS.red : `${TABI_COLORS.red}10`,
              color: on ? '#fff' : TABI_COLORS.red,
              transition: 'background 0.15s',
            }}>{cat}</button>
          );
        })}
      </div>

      {/* Phrase list */}
      <div style={{ flex: 1, overflow: 'auto', padding: '10px 16px 0' }}>
        {phrases.length === 0 ? (
          <div style={{ textAlign: 'center', color: TABI_COLORS.ink3, fontSize: 13, marginTop: 40 }}>載入中…</div>
        ) : visible.length === 0 ? (
          <div style={{ textAlign: 'center', color: TABI_COLORS.ink3, fontSize: 13, marginTop: 40 }}>沒有符合的句子</div>
        ) : (
          <div style={{
            background: TABI_COLORS.card, borderRadius: 18,
            overflow: 'hidden', border: `1px solid ${TABI_COLORS.line}`,
          }}>
            {visible.map((p, i) => (
              <div key={p.id} style={{
                padding: '15px 16px', display: 'flex', alignItems: 'center', gap: 10,
                borderTop: i ? `1px solid ${TABI_COLORS.line}` : 'none',
              }}>
                {activeCat === '全部' && (
                  <div style={{
                    fontSize: 10, fontWeight: 600, color: TABI_COLORS.red,
                    background: `${TABI_COLORS.red}12`, borderRadius: 6,
                    padding: '3px 7px', flexShrink: 0, letterSpacing: '0.03em', whiteSpace: 'nowrap',
                  }}>{p.category}</div>
                )}
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{
                    fontFamily: tabiSerif, fontSize: 15, color: TABI_COLORS.ink,
                    fontWeight: 500, lineHeight: 1.4,
                  }}>{p.japanese}</div>
                  <div style={{ fontSize: 13, color: TABI_COLORS.ink3, marginTop: 4, lineHeight: 1.45 }}>
                    {p.chinese}
                  </div>
                </div>
                <button onClick={() => handleSpeak(p.id, p.japanese)} style={{
                  width: 36, height: 36, borderRadius: 18, border: 'none', flexShrink: 0,
                  background: (speaking === p.id || ttsPending === p.id) ? TABI_COLORS.red : `${TABI_COLORS.red}12`,
                  color: (speaking === p.id || ttsPending === p.id) ? '#fff' : TABI_COLORS.red,
                  display: 'grid', placeItems: 'center', cursor: 'pointer',
                  transition: 'background 0.15s, color 0.15s',
                }}>{(speaking === p.id || ttsPending === p.id) ? <StopIcon size={14} /> : <PlayIcon size={14} />}</button>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

Object.assign(window, {
  TABI_COLORS, tabiFont, tabiSerif,
  TabiTopBar, TabiTabBar,
  HomeIcon, MenuIcon, VoiceIcon, ReceiptIcon, LogIcon, WalletIcon, CameraIcon, MicIcon, PlayIcon, StopIcon, CheckIcon,
  HomeScreen, CameraScreen, MenuResultScreen, OrderConfirmScreen,
  VoiceChatScreen, VoiceHistoryScreen, VoiceDetailScreen,
  ReceiptResultScreen, SignResultScreen, SignChatScreen, PhrasesScreen,
});
