// 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 &&

}
,
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} />
{/* 金額 + 幣別 + 數量 */}
幣別
{/* 幫誰買 */}
幫誰買
{[['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} />
商品圖片(選填)
{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 ? (
) : (
<>
>
)}
,
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 && (
)}
{/* ── 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) => (
))}
)}
{/* 我的清單 (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 && (
)}
{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}
/>
);
}