// shopping.jsx — 購物清單功能 // ── helpers ────────────────────────────────────────────────── function _shopApi(method, path, body) { const opts = { method, headers: { Authorization: `Bearer ${window.tabiToken()}` } }; if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } return fetch('/api/shopping' + path, opts).then(r => { if (r.status === 204) return null; return r.ok ? r.json() : r.json().then(d => Promise.reject(d.detail || '操作失敗')); }); } function _fmtShopAmt(price, currency, qty) { if (!price) return null; const sym = currency === 'JPY' ? '¥' : currency === 'TWD' ? 'NT$' : currency; const total = price * (qty || 1); return `${sym}${total.toLocaleString()}`; } const SHOP_EMOJIS = ['🛍️','💊','📷','👗','👟','🎮','🍫','🧴','🏠','🎁','✏️','🎯']; const PRIORITY_COLOR = { must: '#B94A35', want: '#574B40' }; // ── Image Lightbox ──────────────────────────────────────────── function ImageLightbox({ src, onClose }) { return ReactDOM.createPortal(
e.stopPropagation()} />
, document.body ); } // ── AddEditListSheet ────────────────────────────────────────── function AddEditListSheet({ existing, onClose, onSaved }) { const [title, setTitle] = React.useState(existing?.title || ''); const [emoji, setEmoji] = React.useState(existing?.emoji || '🛍️'); const [saving, setSaving] = React.useState(false); const [err, setErr] = React.useState(''); async function save() { if (!title.trim()) { setErr('請輸入清單名稱'); return; } setSaving(true); try { const body = { title: title.trim(), emoji }; await _shopApi(existing ? 'PATCH' : 'POST', existing ? `/lists/${existing.id}` : '/lists', body); onSaved(); } catch (e) { setErr(String(e)); setSaving(false); } } const inputStyle = { 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: 'inherit', boxSizing: 'border-box', }; return ReactDOM.createPortal(
e.target === e.currentTarget && onClose()}>
{/* drag handle */}
{/* header */}
{existing ? '編輯清單' : '新增清單'}
{/* content */}
清單名稱
setTitle(e.target.value)} placeholder="例:藥妝必買、電器清單" style={inputStyle} />
圖示
{SHOP_EMOJIS.map(e => ( ))}
{err &&
{err}
}
{/* footer */}
, document.body ); } // ── AddChoiceSheet (手動 vs AI) ─────────────────────────────── function AddChoiceSheet({ onManual, onAI, onClose }) { const fileRef = React.useRef(); return ReactDOM.createPortal(
e.target === e.currentTarget && onClose()}>
{/* no capture attr → iOS native picker: Take Photo / Photo Library / Files */} { const f = e.target.files[0]; if (f) onAI(f); }} />
, document.body ); } // ── ImageAnalysisSheet ──────────────────────────────────────── function ImageAnalysisSheet({ listId, initialFile, onClose, onSaved }) { const [step, setStep] = React.useState(initialFile ? 'analyzing' : 'choose'); const [imgPreview, setImgPreview] = React.useState(null); const [result, setResult] = React.useState(null); const [errMsg, setErrMsg] = React.useState(''); const [showForm, setShowForm] = React.useState(false); React.useEffect(() => { if (initialFile) analyze(initialFile); }, []); const fileRef = React.useRef(); async function analyze(file) { setImgPreview(URL.createObjectURL(file)); setStep('analyzing'); try { const fd = new FormData(); fd.append('image', file); const r = await fetch('/api/shopping/analyze-image', { method: 'POST', headers: { Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() }, body: fd, }); const data = await r.json(); if (!r.ok) throw new Error(data.detail || '分析失敗'); setResult(data); setStep('result'); } catch (e) { setErrMsg(String(e.message || e)); setStep('error'); } } if (showForm && result) { return ( ); } if (step === 'choose') { return ReactDOM.createPortal(
e.target === e.currentTarget && onClose()}>
選擇圖片
{ const f = e.target.files[0]; if (f) analyze(f); }} />
, document.body ); } if (step === 'analyzing') { return ReactDOM.createPortal(
{imgPreview && }
AI 分析中…
透過大語言模型辨識商品並搜尋資訊
, document.body ); } if (step === 'error') { return ReactDOM.createPortal(
😕
無法辨識商品
{errMsg}
, document.body ); } // step === 'result' const conf = result?.confidence; const confColor = conf === 'high' ? TABI_COLORS.green : conf === 'medium' ? TABI_COLORS.warn : TABI_COLORS.red; const confLabel = conf === 'high' ? '辨識可信度高' : conf === 'medium' ? '辨識可信度中等' : '辨識可信度低,請確認'; return ReactDOM.createPortal(
{/* AI 警示 */}
🤖
{confLabel}
AI 辨識結果,資訊可能有誤,請自行確認後加入
{/* 商品摘要 */}
{imgPreview && }
{result?.name || '—'}
{result?.brand &&
{result.brand}
}
{result?.price && ≈¥{result.price.toLocaleString()}} {result?.category && {result.category}}
{result?.note &&
{result.note}
} {result?.location &&
📍 {result.location}
}
{/* 搜尋結果 */} {result?.search_results?.length > 0 && ( )} {result?.search_results?.length === 0 && (
未找到網路搜尋結果
)}
, document.body ); } // ── AddEditItemSheet ────────────────────────────────────────── function AddEditItemSheet({ listId, existing, prefill, onClose, onSaved }) { // prefill comes from AI analysis; existing from edit mode const src = existing || prefill || {}; const [name, setName] = React.useState(src.name || ''); const [price, setPrice] = React.useState(src.price ? String(src.price) : ''); const [currency, setCurrency] = React.useState(src.currency || 'JPY'); const [qty, setQty] = React.useState(String(src.quantity || 1)); const [location, setLocation] = React.useState(src.location || ''); const [note, setNote] = React.useState(src.note || ''); const [url, setUrl] = React.useState(src.url || ''); const [priority, setPriority] = React.useState(src.priority || 'want'); const [forWhomType, setForWhomType] = React.useState(src.for_whom ? 'other' : 'self'); const [forWhomName, setForWhomName] = React.useState(src.for_whom || ''); const [saving, setSaving] = React.useState(false); const [err, setErr] = React.useState(''); const [imgFile, setImgFile] = React.useState(null); // use existing URL, or prefill URL from AI analysis (already uploaded to server) const [imgPreview, setImgPreview] = React.useState(existing?.image_url || prefill?.image_url || null); const fileRef = React.useRef(); function pickImg(e) { const f = e.target.files[0]; if (!f) return; setImgFile(f); setImgPreview(URL.createObjectURL(f)); } async function save() { if (!name.trim()) { setErr('請輸入品項名稱'); return; } if (forWhomType === 'other' && !forWhomName.trim()) { setErr('請輸入代買對象的名字'); return; } setSaving(true); try { const body = { name: name.trim(), price: price ? parseInt(price) : null, currency, quantity: parseInt(qty) || 1, location: location.trim() || null, note: note.trim() || null, url: url.trim() || null, priority, for_whom: forWhomType === 'other' ? forWhomName.trim() : null, // include server-side image URL from AI analysis (no re-upload needed) image_url: !imgFile && prefill?.image_url ? prefill.image_url : undefined, }; let item; if (existing) { item = await _shopApi('PATCH', `/lists/${listId}/items/${existing.id}`, body); } else { item = await _shopApi('POST', `/lists/${listId}/items`, body); } if (imgFile && item?.id) { const fd = new FormData(); fd.append('image', imgFile); await fetch(`/api/shopping/lists/${listId}/items/${item.id}/upload`, { method: 'POST', headers: { Authorization: `Bearer ${window.tabiToken()}` }, body: fd, }); } onSaved(); } catch (e) { setErr(String(e)); setSaving(false); } } const inputStyle = { width:'100%', padding:'10px 12px', borderRadius:10, minHeight:42, border:`1px solid ${TABI_COLORS.line}`, background:TABI_COLORS.bg, fontSize:16, color:TABI_COLORS.ink, outline:'none', fontFamily:'inherit', boxSizing:'border-box', }; const selectStyle = { ...inputStyle, appearance:'none', WebkitAppearance:'none', backgroundImage:`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238D8071' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E")`, backgroundRepeat:'no-repeat', backgroundPosition:'right 10px center', paddingRight:28, cursor:'pointer', }; return ReactDOM.createPortal(
e.target === e.currentTarget && onClose()}>
{existing ? '編輯品項' : '新增品項'}
{/* AI prefill warning */} {prefill && (
🤖 AI 自動填入・資訊可能有誤,請確認後儲存
)} {/* 優先度 */}
{[['must','非買不可 🔥'],['want','想買看看']].map(([v,label]) => ( ))}
品項名稱 *
setName(e.target.value)} placeholder="例:龍角散喉糖、Shiseido 防曬" style={inputStyle} /> {/* 金額 + 幣別 + 數量 */}
預估金額(選填)
setPrice(e.target.value)} placeholder="0" style={inputStyle} />
幣別
數量
setQty(e.target.value)} min="1" style={inputStyle} />
{/* 幫誰買 */}
幫誰買
{[['self','自己 🙋'],['other','代買給別人 🎁']].map(([v,label]) => ( ))}
{forWhomType === 'other' && ( setForWhomName(e.target.value)} placeholder="輸入對方名字" style={inputStyle} /> )}
購買地點(選填)
setLocation(e.target.value)} placeholder="例:松本清、唐吉訶德" style={inputStyle} />
備註(尺寸、顏色、品牌…)
setNote(e.target.value)} placeholder="例:粉色款、L號、要買2盒" style={inputStyle} />
商品連結(選填)
setUrl(e.target.value)} placeholder="https://..." style={inputStyle} />
商品圖片(選填)
{imgPreview && ( )}
{err &&
{err}
}
, document.body ); } // ── RequestsReviewSheet ─────────────────────────────────────── function RequestsReviewSheet({ listId, requests, onClose, onRefresh }) { const [rejectId, setRejectId] = React.useState(null); const [loading, setLoading] = React.useState(null); const [expanded, setExpanded] = React.useState(() => new Set()); const [lightbox, setLightbox] = React.useState(null); const pending = requests.filter(r => r.status === 'pending'); const done = requests.filter(r => r.status !== 'pending'); function toggleExpand(key) { setExpanded(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); } async function approve(r) { setLoading(r.id); try { await _shopApi('PATCH', `/requests/${r.id}/approve`); onRefresh(); } catch (e) { alert(String(e)); } finally { setLoading(null); } } async function reject(id) { setLoading(id); try { await _shopApi('PATCH', `/requests/${id}/reject`); setRejectId(null); onRefresh(); } catch (e) { alert(String(e)); } finally { setLoading(null); } } return ReactDOM.createPortal(
e.target === e.currentTarget && onClose()}>
代購申請審核
{pending.length === 0 && (
目前沒有待審核的申請
)} {pending.map(r => (
🙋
{r.requester_name}
申請 {r.items.length} 項 · {new Date(r.created_at).toLocaleDateString('zh-TW')}
待審核
{r.items.map((it, i) => { const key = `${r.id}-${i}`; const hasDetails = !!(it.location || it.note || it.url || it.image_url); const isOpen = expanded.has(key); return (
hasDetails && toggleExpand(key)} style={{ display:'flex', gap:8, alignItems:'center', padding:'8px 0', cursor: hasDetails ? 'pointer' : 'default', }}>
{it.name} {it.priority === 'must' && 必買}
{it.price ? {_fmtShopAmt(it.price, it.currency, it.quantity)} : null} {it.quantity > 1 && ×{it.quantity}} {hasDetails && ( )}
{isOpen && (
{it.image_url && ( setLightbox(it.image_url)} style={{ width:56, height:56, objectFit:'cover', borderRadius:8, flexShrink:0, cursor:'zoom-in', border:`1px solid ${TABI_COLORS.line}`, }} /> )}
{it.location &&
📍 {it.location}
} {it.note &&
📝 {it.note}
} {it.url && ( )}
)}
); })}
))} {done.length > 0 && (
已處理
{done.map(r => (
{r.requester_name} ({r.items.length} 項) {r.status==='approved' ? '已同意' : '已拒絕'}
))}
)}
{/* Reject confirm dialog */} {rejectId && ReactDOM.createPortal(
setRejectId(null)}>
e.stopPropagation()} style={{ background:TABI_COLORS.bg, borderRadius:18, padding:'22px 20px', maxWidth:320, width:'100%', boxShadow:'0 24px 60px rgba(0,0,0,0.25)' }}>
確定拒絕這筆申請?
拒絕後對方需要重新填寫申請,請確認後再操作。
, document.body )} {lightbox && setLightbox(null)} />}
, document.body ); } // ── ShareLinkSheet ──────────────────────────────────────────── function ShareLinkSheet({ listId, hasLink, appBaseUrl, onClose, onTokenChange }) { const [token, setToken] = React.useState(null); const [loading, setLoading] = React.useState(hasLink); // if link exists, fetch token on mount const [copied, setCopied] = React.useState(false); React.useEffect(() => { if (!hasLink) return; _shopApi('POST', `/lists/${listId}/request-token`) .then(d => { setToken(d.token); setLoading(false); }) .catch(() => setLoading(false)); }, []); const link = token ? `${appBaseUrl}/shopping-request?token=${token}` : null; const shareText = link ? [ '想請你幫我填寫 Tabi 代購清單。', '打開連結後可以直接新增想買的商品,不需要登入。', '送出後會先進入待審核,我確認後才會加入購物清單。', link, ].join('\n') : ''; async function generate() { setLoading(true); try { const d = await _shopApi('POST', `/lists/${listId}/request-token`); setToken(d.token); onTokenChange(d.token); } catch (e) { alert(String(e)); } finally { setLoading(false); } } async function revoke() { if (!confirm('停用後舊連結將失效,確定嗎?')) return; setLoading(true); try { await _shopApi('DELETE', `/lists/${listId}/request-token`); setToken(null); onTokenChange(null); } catch (e) { alert(String(e)); } finally { setLoading(false); } } function copy() { if (!link) return; navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); } function share() { if (!link) return; if (navigator.share) { navigator.share({ title: '幫我填寫 Tabi 代購清單', text: shareText, url: link, }); } else { copy(); } } return ReactDOM.createPortal(
e.target === e.currentTarget && onClose()}>
邀請填寫代購清單
把連結傳給對方,對方直接在網頁填寫想買的商品,不需要登入。 填完後出現在「待審核」,你同意後才加入清單。
{loading && !token ? (
產生中…
) : !token ? ( ) : ( <>
分享連結
{link}
)}
, document.body ); } // ── ShoppingListDetailScreen ────────────────────────────────── // ── MoveItemsSheet ─────────────────────────────────────────── function MoveItemsSheetReal({ fromListId, selectedIds, onClose, onMoved }) { const [allLists, setAllLists] = React.useState([]); const [loading, setLoading] = React.useState(true); const [moving, setMoving] = React.useState(null); // list id being moved to const [tripChoice, setTripChoice] = React.useState({}); React.useEffect(() => { _shopApi('GET', '/lists') .then(d => setAllLists(Array.isArray(d) ? d.filter(l => l.id !== fromListId) : [])) .catch(() => {}) .finally(() => setLoading(false)); }, [fromListId]); const standalone = allLists.filter(l => !l.trip_id && l.is_owner); const tripGroups = {}; allLists.filter(l => l.trip_id).forEach(l => { if (!tripGroups[l.trip_id]) tripGroups[l.trip_id] = []; tripGroups[l.trip_id].push(l); }); async function moveTo(toListId) { setMoving(toListId); try { await _shopApi('POST', `/lists/${fromListId}/items/move`, { item_ids: selectedIds, to_list_id: toListId, }); onMoved(); } catch (e) { alert(String(e)); setMoving(null); } } const ListOption = ({ lst, label }) => ( ); return ReactDOM.createPortal(
e.target === e.currentTarget && onClose()}>
移動到哪個清單?
{selectedIds.length} 個品項將被移動
{loading &&
載入中…
} {/* Standalone personal lists */} {standalone.length > 0 && ( <>
個人清單
{standalone.map(lst => )} )} {/* Trip lists */} {Object.entries(tripGroups).map(([tripId, tripLs]) => { const tripName = tripLs[0]?.title?.split(' · ')[0] || '旅程'; const personal = tripLs.find(l => l.visibility === 'personal' && l.is_owner); const shared = tripLs.find(l => l.visibility === 'shared'); return (
✈️ {tripName}
{personal && } {shared && }
); })} {!loading && standalone.length === 0 && Object.keys(tripGroups).length === 0 && (
沒有其他清單可以移動
)}
, document.body ); } // ── ActionsMenu ────────────────────────────────────────────── // A ⋯ button that opens a bottom action sheet function ActionsMenu({ actions }) { // actions: [{ label, icon, color?, onClick, hidden? }] const [open, setOpen] = React.useState(false); const visible = actions.filter(a => !a.hidden); if (!visible.length) return null; return ( <> {open && ReactDOM.createPortal(
setOpen(false)}>
e.stopPropagation()}>
{visible.map((a, i) => ( ))}
, document.body )} ); } // ── HistorySheet ────────────────────────────────────────────── const ACTION_LABEL = { add:'新增', edit:'編輯', delete:'刪除', toggle:'勾選', revert:'還原' }; const ACTION_COLOR = { add: '#55705A', edit: '#365A77', delete: '#B94A35', toggle: '#8D8071', revert: '#BE8A35' }; function _snapSummary(snap) { if (!snap) return null; const parts = []; const sym = snap.currency === 'JPY' ? '¥' : snap.currency === 'TWD' ? 'NT$' : snap.currency || '¥'; if (snap.price) parts.push(`${sym}${Number(snap.price).toLocaleString()}${snap.quantity > 1 ? ` ×${snap.quantity}` : ''}`); if (snap.priority === 'must') parts.push('非買不可🔥'); if (snap.location) parts.push(`📍${snap.location}`); if (snap.note) parts.push(snap.note.slice(0, 20) + (snap.note.length > 20 ? '…' : '')); if (snap.for_whom) parts.push(`代買給 ${snap.for_whom}`); if (typeof snap.is_bought === 'boolean') parts.push(snap.is_bought ? '✓ 已購買' : '未購買'); return parts.length ? parts.join(' · ') : null; } function HistorySheet({ listId, history, onClose, onRevert }) { const [reverting, setReverting] = React.useState(null); async function revert(h) { if (!h.item_id) return; const label = h.action === 'delete' ? `還原已刪除的「${h.item_name}」?` : `還原「${h.item_name}」到此時間點的狀態?`; if (!confirm(label)) return; setReverting(h.id); try { await onRevert(h.item_id, h.id); } catch (e) { alert(String(e)); } finally { setReverting(null); } } function fmtTime(iso) { const d = new Date(iso); return `${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; } // Action descriptions const ACTION_DESC = { add: (h) => '新增了這個品項', delete: (h) => `刪除前:${_snapSummary(h.snapshot) || h.item_name}`, edit: (h) => `修改前:${_snapSummary(h.snapshot) || '—'}`, toggle: (h) => h.snapshot?.is_bought === true ? '取消購買' : '標記已購買', revert: (h) => '還原到先前狀態', }; return ReactDOM.createPortal(
e.target === e.currentTarget && onClose()}>
編輯紀錄
{history.length === 0 && (
📋
尚無編輯紀錄
)} {history.map(h => { const desc = (ACTION_DESC[h.action] || (() => ''))(h); const canRevert = h.item_id && h.action !== 'revert'; return (
{ACTION_LABEL[h.action] || h.action}
{h.item_name || '—'}
{desc &&
{desc}
}
{h.user_display_name && {h.user_display_name} · } {fmtTime(h.created_at)}
{canRevert && ( )}
); })}
, document.body ); } function ShoppingListDetailScreen({ go, listId }) { const [lst, setLst] = React.useState(null); const [editList, setEditList] = React.useState(false); const [addItem, setAddItem] = React.useState(false); // manual const [addChoice, setAddChoice] = React.useState(false); // choice sheet const [aiAnalysis, setAiAnalysis] = React.useState(false); // AI sheet const [editItem, setEditItem] = React.useState(null); const [showBought, setShowBought] = React.useState(false); const [lightbox, setLightbox] = React.useState(null); const [showRequests, setShowRequests] = React.useState(false); const [showShare, setShowShare] = React.useState(false); const [showHistory, setShowHistory] = React.useState(false); const [history, setHistory] = React.useState([]); const [moveMode, setMoveMode] = React.useState(false); const [selectedIds, setSelectedIds] = React.useState([]); const [showMove, setShowMove] = React.useState(false); async function loadHistory() { try { const d = await _shopApi('GET', `/lists/${listId}/history`); setHistory(Array.isArray(d) ? d : []); } catch {} } const [showMust, setShowMust] = React.useState(true); const [showWant, setShowWant] = React.useState(true); const [appBase] = React.useState(() => window.location.origin); async function load() { try { setLst(await _shopApi('GET', `/lists/${listId}`)); } catch { go('shopping'); } } React.useEffect(() => { load(); }, [listId]); async function toggleItem(item) { await _shopApi('PATCH', `/lists/${listId}/items/${item.id}/toggle`); load(); } async function deleteItem(item) { if (!confirm(`刪除「${item.name}」?`)) return; await _shopApi('DELETE', `/lists/${listId}/items/${item.id}`); load(); // Refresh history if it's open if (showHistory) { _shopApi('GET', `/lists/${listId}/history`) .then(d => setHistory(Array.isArray(d) ? d : [])) .catch(() => {}); } } if (!lst) return (
載入中…
); const mustItems = lst.items.filter(i => !i.is_bought && i.priority === 'must').sort((a,b) => a.sort_order - b.sort_order); const wantItems = lst.items.filter(i => !i.is_bought && i.priority !== 'must').sort((a,b) => a.sort_order - b.sort_order); const bought = lst.items.filter(i => i.is_bought); const unbought = [...mustItems, ...wantItems]; const totalEst = unbought.filter(i => i.price).reduce((s, i) => s + i.price * i.quantity, 0); const mustEst = mustItems.filter(i => i.price).reduce((s, i) => s + i.price * i.quantity, 0); const mustCount = mustItems.length; const pendingRequests = (lst.requests || []).filter(r => r.status==='pending').length; return (
go('shopping')} right={
{pendingRequests > 0 && ( )} {/* 編輯 = 進入選取模式(個人清單),在選取模式顯示「取消」 */} {lst.visibility !== 'shared' && ( )} {!moveMode && setShowShare(true) }, { label:'歷史紀錄', icon:'📋', hidden: lst.visibility !== 'shared', onClick: () => { loadHistory(); setShowHistory(true); } }, ]} />}
} />
{/* ── stats row ── */} {lst.items.length > 0 && (
{mustItems.length}
非買不可
{wantItems.length}
想買看看
{bought.length}
已購買
)} {/* ── empty state ── */} {lst.items.length === 0 && (
🛍️
清單還是空的
點右下角 + 開始加入想買的東西
)} {/* ── 非買不可 section ── */} {mustItems.length > 0 && (
{showMust && mustItems.map(item => ( setSelectedIds(prev => prev.includes(item.id) ? prev.filter(id=>id!==item.id) : [...prev, item.id])} onToggle={() => toggleItem(item)} onEdit={() => setEditItem(item)} onDelete={() => deleteItem(item)} onImageClick={() => item.image_url && setLightbox(item.image_url)} /> ))}
)} {/* ── 想買看看 section ── */} {wantItems.length > 0 && (
{showWant && wantItems.map(item => ( setSelectedIds(prev => prev.includes(item.id) ? prev.filter(id=>id!==item.id) : [...prev, item.id])} onToggle={() => toggleItem(item)} onEdit={() => setEditItem(item)} onDelete={() => deleteItem(item)} onImageClick={() => item.image_url && setLightbox(item.image_url)} /> ))}
)} {/* ── 已購買 section ── */} {bought.length > 0 && (
{showBought && bought.map(item => ( setSelectedIds(prev => prev.includes(item.id) ? prev.filter(id=>id!==item.id) : [...prev, item.id])} onToggle={() => toggleItem(item)} onEdit={() => setEditItem(item)} onDelete={() => deleteItem(item)} onImageClick={() => item.image_url && setLightbox(item.image_url)} /> ))}
)}
{editList && setEditList(false)} onSaved={() => { setEditList(false); load(); }} />} {addChoice && { setAddChoice(false); setAddItem(true); }} onAI={f => { setAddChoice(false); setAiAnalysis(f || true); }} onClose={() => setAddChoice(false)} />} {addItem && setAddItem(false)} onSaved={() => { setAddItem(false); load(); }} />} {aiAnalysis && setAiAnalysis(false)} onSaved={() => { setAiAnalysis(false); load(); }} />} {editItem && setEditItem(null)} onSaved={() => { setEditItem(null); load(); }} />} {lightbox && setLightbox(null)} />} {showRequests && setShowRequests(false)} onRefresh={() => { setShowRequests(false); load(); }} />} {showHistory && setShowHistory(false)} onRevert={(itemId, histId) => _shopApi('POST', `/lists/${listId}/items/${itemId}/revert/${histId}`).then(() => { load(); loadHistory(); })} />} {showShare && setShowShare(false)} onTokenChange={() => load()} />} {showMove && setShowMove(false)} onMoved={() => { setShowMove(false); setMoveMode(false); setSelectedIds([]); load(); }} />} {/* Edit mode bottom action bar */} {moveMode && ReactDOM.createPortal(
{selectedIds.length === 0 ? '點品項選取' : `已選 ${selectedIds.length} 項`} {selectedIds.length > 0 && (<> )}
, document.body )} {!moveMode && ReactDOM.createPortal( , document.body )}
); } // ── ShoppingItemCard ────────────────────────────────────────── function ShoppingItemCard({ item, dimmed, hidePriority, showContributor, selectable, selected, onSelect, onToggle, onEdit, onDelete, onImageClick }) { const amtText = _fmtShopAmt(item.price, item.currency, item.quantity); return (
{selectable ? (
{selected ? '✓' : ''}
) : ( )} {item.image_url && ( )}
{item.priority==='must' && !item.is_bought && !hidePriority && ( 必買 )} {item.for_whom && ( 代買給 {item.for_whom} )} {item.name}
{amtText && {amtText}} {item.quantity > 1 && ×{item.quantity}} {item.location && 📍 {item.location}}
{item.note &&
{item.note}
} {item.url && ( 🔗 {item.url.replace(/^https?:\/\/(www\.)?/, '')} )} {/* contributor info: only shown for shared lists */} {showContributor && (
{item.added_by_display_name && !item.is_bought && ( ✏️ {item.added_by_display_name} 新增 )} {item.is_bought && item.bought_by_display_name && ( ✓ {item.bought_by_display_name} 已購買 )} {item.is_bought && !item.bought_by_display_name && ( ✓ 已購買 )}
)} {/* personal list: simple bought indicator */} {!showContributor && item.is_bought && (
✓ 已購買
)}
{!selectable && (
)}
); } // ── ShoppingScreen (list of lists) ─────────────────────────── // ── ListCard ────────────────────────────────────────────────── function ListCard({ lst, go, onDelete, onEdit }) { const remain = lst.item_count - lst.bought_count; const progress = lst.item_count > 0 ? lst.bought_count / lst.item_count : 0; return (
go('shopping_detail', lst.id)} style={{ background:'rgba(255,250,242,0.96)', borderRadius:20, padding:'14px 15px', marginBottom:10, border:`1px solid ${TABI_COLORS.line}`, cursor:'pointer', boxShadow:TABI_COLORS.shadowSoft, display:'flex', alignItems:'center', gap:12, }}>
{lst.emoji || '🛍️'} {lst.pending_requests > 0 && (
{lst.pending_requests}
)}
{lst.visibility === 'shared' && ( 共同 )} {!lst.is_owner && ( 旅伴清單 )} {lst.title}
0?7:0, lineHeight:1.4 }}> {lst.item_count === 0 ? '尚無品項,點進去開始加入想買的東西' : `${remain} 項待買・${lst.bought_count} 項已買`}
{lst.item_count > 0 && (
)}
{lst.is_owner && (
{onEdit && ( )} {onDelete && ( )}
)}
); } // ── ShoppingScreen ──────────────────────────────────────────── function ShoppingScreen({ go }) { const [lists, setLists] = React.useState([]); const [loading, setLoading] = React.useState(true); const [creating, setCreating] = React.useState(false); const [editingList, setEditingList] = React.useState(null); async function load() { setLoading(true); try { const d = await _shopApi('GET', '/lists'); setLists(Array.isArray(d) ? d : []); } catch {} finally { setLoading(false); } } React.useEffect(() => { load(); }, []); async function deleteList(lst) { if (!confirm(`刪除「${lst.title}」清單?清單內所有品項也會一起刪除。`)) return; await _shopApi('DELETE', `/lists/${lst.id}`); load(); } // Group lists const myLists = lists.filter(l => l.is_owner && !l.trip_id); const tripLists = lists.filter(l => l.trip_id); // Group trip lists by trip_id const tripGroups = {}; tripLists.forEach(l => { if (!tripGroups[l.trip_id]) tripGroups[l.trip_id] = []; tripGroups[l.trip_id].push(l); }); const totalPending = lists.reduce((s, l) => s + (l.pending_requests || 0), 0); const totalItems = lists.reduce((s, l) => s + (l.item_count || 0), 0); const totalBought = lists.reduce((s, l) => s + (l.bought_count || 0), 0); const totalRemain = Math.max(0, totalItems - totalBought); return (
0 ? ` · ${totalPending} 待審核` : ''}`} onBack={() => go('home')} right={ } />
{loading &&
載入中…
} {!loading && lists.length === 0 && (
🛍️
還沒有購物清單
建立清單,或新增旅程後會自動建立清單
)} {!loading && lists.length > 0 && (
🛍️
TRIP SHOPPING
今天要買什麼?
個人清單和旅程採買集中管理,先買必需品,再看伴手禮。
{[ ['待買', totalRemain], ['已買', totalBought], ['待審核', totalPending], ].map(([label, value], idx) => (
{value}
{label}
))}
)} {/* 我的清單 (no trip) */} {myLists.length > 0 && (
個人清單
MY LISTS
{myLists.length} 份
{myLists.map(lst => setEditingList(lst)} />)}
)} {/* Trip-linked lists → ONE card per trip */} {Object.keys(tripGroups).length > 0 && (
旅程採買
TRIP LISTS
)} {Object.entries(tripGroups).map(([tripId, tripLs]) => { const tripName = tripLs[0]?.title?.split(' · ')[0] || '旅程清單'; const totalItems = tripLs.reduce((s, l) => s + l.item_count, 0); const totalBought = tripLs.reduce((s, l) => s + l.bought_count, 0); const pending = tripLs.reduce((s, l) => s + (l.pending_requests || 0), 0); const progress = totalItems > 0 ? totalBought / totalItems : 0; return (
go('shopping_trip', tripId)} style={{ background:'rgba(255,250,242,0.96)', borderRadius:20, padding:'15px 16px', marginBottom:11, border:`1px solid ${pending > 0 ? 'rgba(185,74,53,0.24)' : TABI_COLORS.line}`, cursor:'pointer', boxShadow:TABI_COLORS.shadowSoft, display:'flex', alignItems:'center', gap:13, }}>
🛒 {pending > 0 && (
{pending}
)}
✈️ {tripName}
0?7:0, lineHeight:1.4 }}> {totalItems === 0 ? '尚無品項,進入後新增旅程採買' : `${totalItems - totalBought} 項待買・${totalBought} 項已買${pending > 0 ? `・${pending} 待審核` : ''}`}
{totalItems > 0 && (
)}
); })}
{creating && setCreating(false)} onSaved={() => { setCreating(false); load(); }} />} {editingList && setEditingList(null)} onSaved={() => { setEditingList(null); load(); }} />}
); } // ── TripShoppingScreen ──────────────────────────────────────── // Combined view: personal section + shared section, shown inside the trip's 清單 tab function TripShoppingScreen({ tripId, onTopBarRight }) { const [lists, setLists] = React.useState([]); const [loading, setLoading] = React.useState(true); const [tab, setTab] = React.useState('personal'); // 'personal' | 'shared' const [addSheet, setAddSheet] = React.useState(false); const [addChoice, setAddChoice] = React.useState(false); const [aiAnalysis, setAiAnalysis] = React.useState(false); const [editItem, setEditItem] = React.useState(null); const [lightbox, setLightbox] = React.useState(null); const [showMust, setShowMust] = React.useState(true); const [showWant, setShowWant] = React.useState(true); const [showBought, setShowBought] = React.useState(false); const [detail, setDetail] = React.useState(null); const [showHistory, setShowHistory] = React.useState(false); const [history, setHistory] = React.useState([]); const [moveMode, setMoveMode] = React.useState(false); const [selectedIds, setSelectedIds] = React.useState([]); const [showMove, setShowMove] = React.useState(false); const [showShare, setShowShare] = React.useState(false); const [showRequests, setShowRequests] = React.useState(false); const [appBase] = React.useState(() => window.location.origin); async function loadLists() { setLoading(true); try { const d = await _shopApi('GET', `/trips/${tripId}/lists`); setLists(Array.isArray(d) ? d : []); } catch {} finally { setLoading(false); } } React.useEffect(() => { let cancelled = false; setLoading(true); _shopApi('GET', `/trips/${tripId}/lists`) .then(d => { if (!cancelled) setLists(Array.isArray(d) ? d : []); }) .catch(() => {}) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [tripId]); const myPersonalList = lists.find(l => l.visibility === 'personal' && l.is_owner); const sharedList = lists.find(l => l.visibility === 'shared'); const activeList = tab === 'personal' ? myPersonalList : sharedList; React.useEffect(() => { if (!activeList) { setDetail(null); return; } let cancelled = false; _shopApi('GET', `/lists/${activeList.id}`) .then(d => { if (!cancelled) setDetail(d); }) .catch(() => {}); return () => { cancelled = true; }; }, [activeList?.id]); function reload() { if (!activeList) return; _shopApi('GET', `/lists/${activeList.id}`).then(setDetail).catch(() => {}); } function openHistory() { if (!activeList) return; _shopApi('GET', `/lists/${activeList.id}/history`) .then(d => { setHistory(Array.isArray(d) ? d : []); setShowHistory(true); }) .catch(() => {}); } const btnStyle = (active) => ({ minHeight:34, padding:'6px 11px', borderRadius:10, fontSize:12, fontWeight:700, cursor:'pointer', border:`1px solid ${active ? TABI_COLORS.blue : TABI_COLORS.line}`, background: active ? `${TABI_COLORS.blue}10` : 'transparent', color: active ? TABI_COLORS.blue : TABI_COLORS.ink2, }); // Push action buttons to parent TopBar when onTopBarRight is provided const pendingCount = activeList?.pending_requests || 0; React.useEffect(() => { if (!onTopBarRight) return; onTopBarRight(
{/* 申請 badge */} {pendingCount > 0 && !moveMode && ( )} {/* 編輯 — both personal and shared */} {activeList && ( )} {/* ⋯ secondary actions */} {!moveMode && setShowShare(true) }, { label:'歷史紀錄', icon:'📋', hidden: tab !== 'shared', onClick: openHistory }, ]} />}
); }, [tab, moveMode, activeList?.id, pendingCount, onTopBarRight]); async function toggleItem(item) { await _shopApi('PATCH', `/lists/${activeList.id}/items/${item.id}/toggle`); reload(); } async function deleteItem(item) { if (!confirm(`刪除「${item.name}」?`)) return; await _shopApi('DELETE', `/lists/${activeList.id}/items/${item.id}`); reload(); if (showHistory) openHistory(); } if (loading) return
載入中…
; const items = detail?.items || []; const mustItems = items.filter(i => !i.is_bought && i.priority === 'must').sort((a,b) => a.sort_order-b.sort_order); const wantItems = items.filter(i => !i.is_bought && i.priority !== 'must').sort((a,b) => a.sort_order-b.sort_order); const boughtItems = items.filter(i => i.is_bought); const accentColor = tab === 'personal' ? TABI_COLORS.red : TABI_COLORS.blue; // summary counts for the toggle badges const pCount = (myPersonalList?.item_count || 0) - (myPersonalList?.bought_count || 0); const sCount = (sharedList?.item_count || 0) - (sharedList?.bought_count || 0); return (
{/* ── Tab toggle ── */}
{[ { id:'personal', label:'🙋 個人', desc:'只有你', count: pCount }, { id:'shared', label:'👥 共同', desc:'全員可見', count: sCount }, ].map(t => ( ))} {/* Inline fallback buttons (used when embedded in trip tab, no external TopBar) */} {!onTopBarRight && ( <> {pendingCount > 0 && !moveMode && ( )} {activeList && ( )} {!moveMode && setShowShare(true) }, { label:'歷史紀錄', icon:'📋', hidden: tab !== 'shared', onClick: openHistory }, ]} />} )}
{/* ── Content ── */}
{!activeList ? (
清單建立中…
) : !detail ? (
載入中…
) : items.length === 0 ? (
🛍️
{tab==='personal' ? '個人清單還是空的' : '共同清單還是空的'}
點右下角 + 新增品項
) : ( <> {mustItems.length > 0 && ( <> {showMust && mustItems.map(item => ( setSelectedIds(prev => prev.includes(item.id) ? prev.filter(id=>id!==item.id) : [...prev, item.id])} onToggle={() => toggleItem(item)} onEdit={() => setEditItem(item)} onDelete={() => deleteItem(item)} onImageClick={() => item.image_url && setLightbox(item.image_url)} /> ))} )} {wantItems.length > 0 && ( <> {showWant && wantItems.map(item => ( setSelectedIds(prev => prev.includes(item.id) ? prev.filter(id=>id!==item.id) : [...prev, item.id])} onToggle={() => toggleItem(item)} onEdit={() => setEditItem(item)} onDelete={() => deleteItem(item)} onImageClick={() => item.image_url && setLightbox(item.image_url)} /> ))} )} {boughtItems.length > 0 && ( <> {showBought && boughtItems.map(item => ( setSelectedIds(prev => prev.includes(item.id) ? prev.filter(id=>id!==item.id) : [...prev, item.id])} onToggle={() => toggleItem(item)} onEdit={() => setEditItem(item)} onDelete={() => deleteItem(item)} onImageClick={() => item.image_url && setLightbox(item.image_url)} /> ))} )} )}
{addSheet && activeList && ( setAddSheet(false)} onSaved={() => { setAddSheet(false); reload(); }} /> )} {addChoice && activeList && { setAddChoice(false); setAddSheet(true); }} onAI={f => { setAddChoice(false); setAiAnalysis(f || true); }} onClose={() => setAddChoice(false)} />} {aiAnalysis && activeList && setAiAnalysis(false)} onSaved={() => { setAiAnalysis(false); reload(); }} />} {editItem && activeList && ( setEditItem(null)} onSaved={() => { setEditItem(null); reload(); }} /> )} {lightbox && setLightbox(null)} />} {showRequests && activeList && detail && ( setShowRequests(false)} onRefresh={() => { setShowRequests(false); reload(); // reload lists to update pending_requests count _shopApi('GET', `/trips/${tripId}/lists`).then(d => setLists(Array.isArray(d) ? d : [])).catch(() => {}); }} /> )} {showHistory && activeList && ( setShowHistory(false)} onRevert={(itemId, histId) => _shopApi('POST', `/lists/${activeList.id}/items/${itemId}/revert/${histId}`).then(() => { reload(); openHistory(); })} /> )} {showMove && activeList && ( setShowMove(false)} onMoved={() => { setShowMove(false); setMoveMode(false); setSelectedIds([]); reload(); }} /> )} {showShare && activeList && ( setShowShare(false)} onTokenChange={() => { _shopApi('GET', `/trips/${tripId}/lists`).then(d => setLists(Array.isArray(d) ? d : [])).catch(() => {}); }} /> )} {/* Edit mode action bar */} {moveMode && ReactDOM.createPortal(
{selectedIds.length === 0 ? '點品項選取' : `已選 ${selectedIds.length} 項`} {selectedIds.length > 0 && (<> {/* 移動 only available for personal lists (not shared) */} {tab === 'personal' && ( )} )}
, document.body )} {/* FAB (hidden in move mode) */} {activeList && !moveMode && ReactDOM.createPortal( , document.body )}
); } // ── TripShoppingPage (standalone wrapper with TopBar) ───────── function TripShoppingPage({ go, tripId }) { const [tripName, setTripName] = React.useState('採購清單'); const [topBarRight, setTopBarRight] = React.useState(null); React.useEffect(() => { _shopApi('GET', `/trips/${tripId}/lists`) .then(d => { if (Array.isArray(d) && d.length) { const name = d[0]?.title?.split(' · ')[0]; if (name) setTripName(name); } }) .catch(() => {}); }, [tripId]); return (
go('shopping')} right={topBarRight} />
); }