// 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 (
{onBack && ( )}
{subtitle && (
{subtitle}
)}
{title}
{right}
); } 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 (
{tabs.map(t => { const on = active === t.id; const Icon = t.icon; return ( ); })}
); } // ─────────── line icons ─────────── const stroke = (active) => ({ fill: 'none', stroke: 'currentColor', strokeWidth: active ? 2 : 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', }); function HomeIcon({ size = 24, active }) { return ( ); } function MenuIcon({ size = 24, active }) { return ( ); } function VoiceIcon({ size = 24, active }) { return ( ); } function ReceiptIcon({ size = 24, active }) { return ( ); } function TicketIcon({ size = 24, active }) { return ( ); } function LogIcon({ size = 24, active }) { return ( ); } function WalletIcon({ size = 24, active }) { return ( ); } function CameraIcon({ size = 24, active }) { return ( ); } function MicIcon({ size = 24, active }) { return ( ); } function PlayIcon({ size = 16 }) { return ( ); } function StopIcon({ size = 16 }) { return ( ); } function CheckIcon({ size = 14 }) { return ( ); } // ─────────── 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 ? ( ) : ( ); } 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 (
{label}
{tag}
{[['default', '預設'], ['custom', '自訂']].map(([v, lbl]) => ( ))}
{mode === 'default' && (
使用網站端點
)} {mode === 'custom' && (
Base URL
{ setUrl(e.target.value); setTestState('idle'); }} placeholder={urlPlaceholder} style={_settingsInp} />
API Key
{ setApiKey(e.target.value); setTestState('idle'); }} placeholder="sk-..." style={{ ..._settingsInp, paddingRight: 42 }} />
模型名稱
setModel(e.target.value)} placeholder={modelPlaceholder} style={_settingsInp} />
{testState !== 'idle' && testState !== 'testing' && (
{testState === 'ok' ? '✓ ' : '✗ '}{testMsg}
)}
)}
); } 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 }) => (
{label} {format(value)}{suffix}
onChange(Number(e.target.value))} style={{ width: '100%', accentColor: TABI_COLORS.red }} />
); return (
語音設定 {voice ? _ttsVoiceDisplayName(voice, labels) : '瀏覽器語音'} · {Math.round(volume * 100)}% · {rate.toFixed(2)}x
{key ? ( ) : (
此語言目前使用瀏覽器內建語音。
)}
Math.round(v * 100)} onChange={v => patch({ tts_volume: v })} /> Number(v).toFixed(2)} onChange={v => patch({ tts_rate: v })} />
); } 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 }) => (
{label}
{format(value)}{suffix}
setValue(Number(e.target.value))} style={{ width: '100%', accentColor: TABI_COLORS.red }} />
); 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 (
{label}
); }; return (
語音播放(TTS)
Fish Speech 聲音選擇
Math.round(v * 100)} /> Number(v).toFixed(2)} />
{status.text}
); } 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(
{/* Header */}
AI 端點設定
設定值僅存於本機,不會上傳
{/* LLM section */}
{/* ASR section */}
🔒 API Key 只存於你的手機,不會傳送至本網站資料庫。
, 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 (
{/* Camera mode choice sheet */} {camSheet && (
setCamSheet(false)} style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 100, display: 'flex', alignItems: 'flex-end', }}>
e.stopPropagation()} style={{ width: '100%', background: TABI_COLORS.bg, borderRadius: '20px 20px 0 0', padding: '12px 20px 36px', }}>
選擇拍照模式
{[ { id: 'camera', title: '菜單翻譯', desc: '掃描菜單・選料理・生成點餐句', Icon: MenuIcon, tone: TABI_COLORS.red }, { id: 'sign', title: '拍照翻譯', desc: '路標・看板・標示等任意文字翻譯', Icon: CameraIcon, tone: TABI_COLORS.warn }, ].map(opt => ( ))}
)} {/* hero */}
旅 · TABI · 日本旅行
{(() => { let isCustom = false; try { isCustom = JSON.parse(localStorage.getItem('TABI_LLM_CONFIG') || '{}').mode === 'custom'; } catch {} return ( ); })()}
こんにちは、
{userName || '旅人'}さん。
{/* big primary CTA */}
{/* feature grid */}
{features.map(f => ( ))}
{/* common phrases */}
{sampledPhrases.length === 0 ? (
載入中…
) : sampledPhrases.map((p, i) => (
{p.category || p.cat}
{p.japanese || p.jp}
{p.chinese || p.zh}
))}
{scanReceiptOpen && ( setScanReceiptOpen(false)} onSaved={() => setScanReceiptOpen(false)} onTripCreated={() => loadHomeTrips()} /> )} {showLLMSettings && ( setShowLLMSettings(false)} /> )}
); } 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 ( ); } 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 (
go('home')} />
setQuery(e.target.value)} placeholder="搜尋店名、分類或優惠內容" style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: TABI_COLORS.ink, fontSize: 14, fontFamily: tabiFont, }} /> {query && ( )}
{cats.map(cat => { const on = category === cat; return ( ); })}
{filtered.length} 張優惠券
第 {safePage} / {totalPages} 頁
{pageItems.length ? pageItems.map(coupon => ( )) : (
找不到符合條件的優惠券
)}
{totalPages > 1 && (
)}
); } function SectionLabel({ jp, zh, right }) { return (
{zh}
{jp}
{right &&
{right}
}
); } // ─────────── 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 (
{ addFiles(e.target.files); e.target.value = ''; }} /> { addFiles(e.target.files); e.target.value = ''; }} /> {/* header */}
{labels.jp}
{labels.zh}
{files.length > 0 && (
{files.length} 張
)}
{/* preview area */}
{!hasFiles && (
📷
點「拍照」直接拍攝
點「相簿」可一次選多張
菜單多頁可分開拍後一起送出
)} {hasFiles && (
{previews.map((url, i) => (
{files.length > 1 && (
第 {i + 1} 頁
)}
))}
)} {uploading && (
{mode === 'sign' ? (uploadProgress || 'AI 翻譯中…') : `AI 分析菜單中${files.length > 1 ? `(共 ${files.length} 頁)` : ''}…`}
)} {error && !uploading && (
{error}
)} {/* Alert modal — centered overlay for recognizable error types */} {alertModal && (
{alertModal.emoji}
{alertModal.title}
{alertModal.desc}
)}
{/* bottom bar */}
{hasFiles && ( )}
); } // ─────────── 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 (
go(backTo)} />
載入菜單中…
); if (error) return (
go(backTo)} />
⚠️
{error}
); return (
go(backTo)} right={!editMode && ( )} /> {/* ── Trip picker sheet ── */} {tripSheet && ReactDOM.createPortal(
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', }}>
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', }}>
選擇旅程
將此菜單關聯到旅程
{trips === null ? (
載入中…
) : trips.length === 0 ? (
還沒有旅程,請先在旅行頁建立旅程
) : trips.map(t => ( ))}
, document.body )} {/* ── Item edit/add sheet ── */} {editingItem !== null && ReactDOM.createPortal(
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', }}>
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', }}>
{editingItem.id ? '編輯品項' : '新增品項'}
{[ { label: '原文名稱', key: 'original_name', placeholder: '原文(日文、英文等)' }, { label: '中文譯名', key: 'translated_name', placeholder: '中文名稱(可留空)' }, { label: '價格(円)', key: 'price', placeholder: '0', inputMode: 'numeric' }, ].map(({ label, key, placeholder, inputMode }) => (
{label}
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', }} />
))}
分類
{['主食', '飲品'].map(cat => ( ))}
{editingItem.id && ( )}
, document.body )} {/* ── Edit toolbar — only shown in edit mode, pinned below title bar ── */} {editMode && (
編輯模式
)}
{/* ── Restaurant header card ── */} { const f = e.target.files?.[0]; if (f) uploadImage(f); e.target.value = ''; }} />
{/* image area */}
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 ? ( ) : (
{localName || '未命名店家'}
點此拍攝或上傳店家照片
)} {uploadingImg && (
)}
{/* name row */}
{editingName ? (
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', }} />
) : (
{localName || '未命名店家'}
)}
{menu.source_lang} 菜單 · {menu.items.length} 道料理
{/* trip selector row */}
{localTripId ? (trips?.find(t => t.id === localTripId)?.name || '旅程已連結') : '未連結旅程'}
{/* ── Menu items ── */}
{menu.items.length === 0 && (
未能解析出菜單項目,請重新拍攝
)} {menu.items.map(it => { const q = usingMembers ? memberQtySum(memberCart[it.id] || {}) : (cart[it.id] || 0); return (
{editMode ? ( ) : it.category && (
{it.category}
)}
{it.translated_name || it.original_name}
原文 · {it.original_name}
{it.translated_desc && (
{it.translated_desc}
)}
{it.price != null ? ( <> ¥{it.price.toLocaleString()} ≈ NT${Math.round(it.price * 0.21)} ) : ( 價格未標示 )}
{!usingMembers && ( q === 0 ? ( ) : (
{q}
) )}
{usingMembers && !editMode && (
{members.map(m => { const mQty = memberCart[it.id]?.[m.id] || 0; const sel = mQty > 0; return (
{sel && ( )}
); })}
)}
); })}
{totalQty > 0 && (
{totalQty}
共 {totalQty} 項
{totalPrice > 0 && (
¥{totalPrice.toLocaleString()}
)}
確認點餐 ›
)}
); } 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 (
go('menuResult')} />
{/* ── 點餐明細 ── */}
{orderItems.map((it, i) => { const memberEntries = it.member_qtys ? Object.entries(it.member_qtys).filter(([, q]) => q > 0) : []; const hasMemberQtys = memberEntries.length > 0; return (
×{it.quantity}
{it.name}
{it.jp_name}
{it.price != null && (
¥{(it.price * it.quantity).toLocaleString()}
)}
{hasMemberQtys && (
{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 (
{m.initial || m.display_name?.[0]} {m.display_name} ×{qty} {perPrice != null && ( ¥{perPrice.toLocaleString()} )}
); })}
)}
); })} {total > 0 && (
合計
¥{total.toLocaleString()} ≈ NT${Math.round(total * 0.21)}
)}
{/* ── 個人小計 ── */} {(() => { 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 (
{entries.map(([mid, amt], i) => { const m = tripMembers.find(m => m.id === mid); if (!m) return null; return (
{m.initial || m.display_name?.[0]} {m.display_name} ¥{amt.toLocaleString()} ≈ NT${Math.round(amt * 0.21)}
); })}
); })()} {/* ── 備註 ── */}