/*__IIFE_WRAPPED__*/
;(function(){
// screens-sub.jsx — Amulet Map / Wish List
const { useState: useSub, useEffect: useESub } = React;
const {
useApp: useAppS, Icon: IconS, Btn: BtnS, Chip: ChipS, Card: CardS, Avatar: AvatarS,
ImgSlot: ImgSlotS, Eyebrow: EyebrowS, TopBar: TopBarS, Body: BodyS,
Field: FieldS, BottomNav: BottomNavS, GoogleMapView: GMapS, NAV_H, SAFE_TOP, Amulet,
} = window;
const ME_FALLBACK = 'あなた';
const PARTNER_FALLBACK = 'パートナー';
const SHIBUYA_CENTER = { lat: 35.6635, lng: 139.6960 };
/* ── Mini spot confirmation card — defined outside AddSpotModal to avoid remount ── */
function MiniSpotCard({ spot, confirmed, onConfirm, onRetry }) {
return (
{spot.cat}
{spot.lat.toFixed(4)}, {spot.lng.toFixed(4)}
{confirmed?.name === spot.name && (
)}
);
}
/* ── Add Spot modal — chat-first. 画像→detectSpot / テキスト→手動候補 ── */
function AddSpotModal({ onClose, live, toast }) {
const { useState: useS, useRef: useR, useEffect: useE } = React;
const isLive = !!live?.coupleId;
const [msgs, setMsgs] = useS([
{ who: 'ai', text: '何を追加しますか?\n場所の写真を送るか、テキストで教えていただければ一緒に探します 📍' }
]);
const [input, setInput] = useS('');
const [confirmed, setConfirmed] = useS(null);
const [imageLoading, setImageLoading] = useS(false);
const [done, setDone] = useS(false);
const [lastImage, setLastImage] = useS(null); // { base64, mimeType } — 直近の画像。テキストヒントで再推論するため保持
const listRef = useR(null);
const fileRef = useR(null);
useE(() => {
if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
}, [msgs, imageLoading]);
const addMsg = (msg) => setMsgs(m => [...m, msg]);
// 会話履歴を detectSpot の chatContext({ role, text }[])へ変換。
// バックエンドが直近20件・4000字に丸めるので全件渡してよい。
const toChatContext = (list) =>
list
.map(m => ({ role: m.who === 'ai' ? 'assistant' : 'user', text: (m.text || '').trim() }))
.filter(m => m.text);
// 画像 or テキストで場所推定を実行し、結果をチャットに反映(handleFile / sendChat 共通)。
// base64=null かつ textQuery を渡すとテキストのみ検索(Places API)になる。
const runDetect = async (base64, mimeType, chatContext, textQuery) => {
setImageLoading(true);
try {
const r = await Amulet.detectSpot(base64, mimeType, chatContext, textQuery);
setImageLoading(false);
if (r.detected && r.spot) {
const spot = { name: r.spot.name, lat: r.spot.lat, lng: r.spot.lng, note: r.spot.note, cat: r.spot.category,
photoUrl: r.spot.photoUrl, placeId: r.spot.placeId, address: r.spot.address, rating: r.spot.rating, mapsUri: r.spot.mapsUri };
setConfirmed(spot);
addMsg({ who: 'ai', text: '見つかりました!追加したいのはこの場所ですか?', spot });
} else {
addMsg({ who: 'ai', text: base64
? 'この写真からは場所が分かりませんでした…。お店の名前や地域をテキストで教えてもらえますか?'
: '場所が見つかりませんでした…。別の名前や地域で試してみてもらえますか?' });
}
} catch (err) {
console.error(err);
setImageLoading(false);
addMsg({ who: 'ai', text: 'ごめんなさい、解析に失敗してしまいました。もう一度試してみてください。' });
}
};
const handleImageAttach = () => {
if (imageLoading || confirmed) return;
if (fileRef.current) fileRef.current.click(); // 画像 → detectSpot(実API)で場所特定
};
const handleFile = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
const ctx = toChatContext(msgs); // 添付前までの会話をヒントに
try {
const { base64, mimeType } = await Amulet.fileToBase64(file);
setLastImage({ base64, mimeType }); // テキストヒントで再推論できるよう保持
// アップロードした画像そのものをチャット履歴に表示(テキスト表記より内容が分かりやすい)。
addMsg({ who: 'user', image: `data:${mimeType};base64,${base64}` });
await runDetect(base64, mimeType, ctx);
} catch (err) {
console.error(err);
addMsg({ who: 'ai', text: 'ごめんなさい、画像の解析に失敗してしまいました。テキストでも教えてもらえますか?' });
}
};
const sendChat = () => {
if (!input.trim() || confirmed || imageLoading) return;
const text = input;
setInput('');
const nextMsgs = [...msgs, { who: 'user', text }]; // 送ったヒントを確実に文脈へ含める
addMsg({ who: 'user', text });
if (lastImage) {
// 保持画像 + ヒントを含む会話で再推論(テキスト情報を場所検索の入力として活用)。
runDetect(lastImage.base64, lastImage.mimeType, toChatContext(nextMsgs));
return;
}
// 画像なし: テキストのみで Places API 検索。
runDetect(null, null, toChatContext(nextMsgs), text);
};
const handleConfirm = async () => {
const spot = confirmed;
setConfirmed(null);
if (isLive) {
try {
await Amulet.addPin(live.coupleId, { name: spot.name, category: spot.cat, lat: spot.lat, lng: spot.lng, note: spot.note,
photoUrl: spot.photoUrl, placeId: spot.placeId, address: spot.address, rating: spot.rating, mapsUri: spot.mapsUri });
} catch (e) {
console.error(e);
toast && toast(Amulet.friendlyError(e));
addMsg({ who: 'ai', text: '追加に失敗してしまいました。もう一度試してみてください。' });
return;
}
}
setDone(true);
addMsg({ who: 'ai', text: `「${spot.name}」をAmulet Map に追加しました ✨` });
setTimeout(onClose, 1400);
};
const handleRetry = () => {
setConfirmed(null);
addMsg({ who: 'ai', text: 'なるほど。もう少し詳しく教えてもらえますか?' });
};
return (
{/* backdrop */}
{/* sheet */}
{/* drag handle */}
{/* header */}
{/* message list */}
{msgs.map((m, i) => (
{m.image ? (

) : (
{m.text}
)}
{m.spot &&
}
))}
{imageLoading && (
)}
{/* input bar */}
);
}
/* ════════════════ AMULET MAP ════════════════ */
/* ── スポット全画面詳細(大きな画像+説明+削除)── */
function SpotDetail({ pin, whoName, whoColor, onClose, onDelete, deleting }) {
const [confirm, setConfirm] = useSub(false);
return (
{/* hero image */}
{/* body */}
{whoName(pin.who)}が追加
· {pin.cat}
{pin.name}
{pin.note &&
{pin.note}
}
{pin.address && (
{pin.address}
)}
{pin.rating != null && (
評価 {pin.rating}
)}
{pin.mapsUri && (
Googleマップで開く
)}
{/* delete */}
{!confirm ? (
) : (
この場所を削除しますか?
Amulet Map から「{pin.name}」を削除します。元に戻せません。
)}
);
}
function AmuletMapScreen() {
const { go, live, toast } = useAppS();
const [view, setView] = useSub('map');
const [filter, setFilter] = useSub('すべて');
const [sel, setSel] = useSub(null);
const [detail, setDetail] = useSub(null); // 全画面詳細で表示中のピン
const [deleting, setDeleting] = useSub(false);
const [addSpotOpen, setAddSpotOpen] = useSub(false);
const [livePins, setLivePins] = useSub(null);
const [locating, setLocating] = useSub(false);
const mapRef = React.useRef(null); // 全体表示・現在地・ピン選択ズームで地図を直接操作するため
const isLive = !!live.coupleId;
const handleDelete = async () => {
if (!detail) return;
setDeleting(true);
try {
await Amulet.deletePin(live.coupleId, detail.id);
setDetail(null);
setSel(null);
toast && toast('🗑 削除しました');
} catch (e) {
console.error(e);
toast && toast(Amulet.friendlyError(e));
} finally {
setDeleting(false);
}
};
useESub(() => {
if (!isLive) return;
return Amulet.listenPins(live.coupleId, docs => {
setLivePins(docs.map(d => ({
id: d.id, name: d.name, cat: d.category || 'スポット',
who: d.addedBy === live.user?.uid ? 'me' : 'partner',
note: d.note || '', pos: { lat: d.lat, lng: d.lng },
photo: d.photoUrl, address: d.address, rating: d.rating, mapsUri: d.mapsUri,
})));
});
}, [live.coupleId]);
const myName = live.userDoc?.name || ME_FALLBACK;
const partnerName = live.partner?.name || PARTNER_FALLBACK;
const allPins = isLive ? (livePins || []) : [];
const cats = ['すべて', ...new Set(allPins.map(p => p.cat))];
const pins = allPins.filter(p => filter === 'すべて' || p.cat === filter);
const whoColor = w => (w === 'me' ? 'var(--me)' : 'var(--partner)');
const whoName = w => (w === 'me' ? myName : partnerName);
// ピンの色は「選択状態」で分ける:未選択はAmuletのプライマリ(紫)、選択中は赤で強調。
const markerColor = selected => {
const cs = getComputedStyle(document.documentElement);
if (selected) return '#E5484D';
return cs.getPropertyValue('--amethyst').trim() || '#8E7DA6';
};
// 全ピンが収まる倍率に自動フィットするための署名(座標のみ/選択では変化しない)。
const fitSignature = pins.map(p => `${p.pos.lat.toFixed(5)},${p.pos.lng.toFixed(5)}`).sort().join('|');
const SELECT_ZOOM = 16;
// 全体表示ボタン:全ピンが収まるビューへ、なめらかに引いて戻す。
const fitAll = () => {
const map = mapRef.current;
if (!map || !window.google) return;
const positions = pins.map(p => p.pos).filter(Boolean);
if (positions.length === 0) { window.mapFlyTo(map, SHIBUYA_CENTER, 15); return; }
if (positions.length === 1) { window.mapFlyTo(map, positions[0], 15); return; }
window.mapFlyToBounds(map, positions);
};
// ピン選択:選択トグル。選択時はその場所へ、パン+段階ズームでなめらかに寄る。
const selectPin = (p) => {
const next = sel?.id === p.id ? null : p;
setSel(next);
if (next && mapRef.current) {
const z = Math.max(mapRef.current.getZoom(), SELECT_ZOOM);
window.mapFlyTo(mapRef.current, p.pos, z);
}
};
// 現在地ボタン:位置情報の許可があれば現在地へ移動+ズームイン。
const locateMe = () => {
const map = mapRef.current;
if (!map || !navigator.geolocation) { toast && toast('この端末では現在地を取得できません'); return; }
setLocating(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setLocating(false);
window.mapFlyTo(map, { lat: pos.coords.latitude, lng: pos.coords.longitude }, SELECT_ZOOM);
},
() => { setLocating(false); toast && toast('現在地を取得できませんでした'); },
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 30000 }
);
};
return (
{[['map', 'map'], ['list', 'note']].map(([id, ic]) => (
))}
} />
{/* filters */}
{cats.map(c => { setFilter(c); setSel(null); }} color="var(--amethyst)">{c})}
{view === 'map' ? (
/* map view — paddingBottom prevents BottomNav overlap */
{/* map rounded container */}
{ mapRef.current = map; }}
markers={pins.map(p => ({
id: p.id, pos: p.pos, name: p.name,
color: markerColor(sel?.id === p.id),
active: sel?.id === p.id,
onClick: () => selectPin(p),
}))}
style={{ borderRadius: 0 }}
/>
{/* map controls — 全体表示 / 現在地 */}
{/* legend */}
{[['スポット', 'var(--amethyst)'], ['選択中', '#E5484D']].map(([l, c]) => (
{l}
))}
{/* empty state */}
{isLive && livePins && livePins.length === 0 && (
まだピンがありません
右下の+から行きたい場所を追加
)}
{/* selected card — outside overflow:hidden so it's always visible */}
{sel && (
setDetail(sel)} style={{ margin: '0 16px', background: 'var(--surface)', borderRadius: '0 0 24px 24px', border: '1px solid var(--line)', borderTop: 'none', padding: '14px 15px 16px', animation: 'floatup .22s ease', display: 'flex', gap: 12, flexShrink: 0, cursor: 'pointer' }}>
{whoName(sel.who)}が追加 · {sel.cat}
{sel.name}
{sel.note}
)}
) : (
{pins.map(p => (
setDetail(p)} style={{ display: 'flex', gap: 12, alignItems: 'center', background: 'var(--surface)', borderRadius: 16, padding: 13, border: '1px solid var(--line)', cursor: 'pointer' }}>
{whoName(p.who)} · {p.cat}
{p.name}
{p.note}
))}
{pins.length === 0 &&
まだピンがありません
}
)}
{/* FAB — visible in both map and list modes, sits above BottomNav */}
{addSpotOpen && setAddSpotOpen(false)} live={live} toast={toast} />}
{detail && (
setDetail(null)} onDelete={handleDelete} deleting={deleting} />
)}
);
}
/* ════════════════ WISH LIST ════════════════ */
function WishListScreen() {
const { go, live } = useAppS();
const isLive = !!live.coupleId;
const [liveItems, setLiveItems] = useSub(null); // Firestore items 配列そのまま
const [tab, setTab] = useSub('todo');
const [adding, setAdding] = useSub('');
useESub(() => {
if (!isLive) return;
return Amulet.listenWishlist(live.coupleId, setLiveItems);
}, [live.coupleId]);
const partnerName = live.partner?.name || PARTNER_FALLBACK;
/* items は {text, category, done, createdAt}。UIの形に整える(実データのみ) */
const wishes = (liveItems || []).map((it, i) => ({ id: `${i}`, text: it.text, cat: it.category || 'メモ', pri: it.pri || '中', done: !!it.done, _raw: it }));
const persist = (next) => {
// UI形 → Firestore items 形に戻して全置き換え保存
const items = next.map(w => ({ ...(w._raw || {}), text: w.text, category: w.cat, done: w.done, createdAt: (w._raw && w._raw.createdAt) || new Date().toISOString() }));
Amulet.saveWishlist(live.coupleId, items).catch(console.error);
};
const toggle = id => {
if (!isLive) return;
const next = wishes.map(w => w.id === id ? { ...w, done: !w.done } : w);
setLiveItems(next.map(w => ({ ...(w._raw || {}), text: w.text, category: w.cat, done: w.done })));
persist(next);
};
const add = () => {
if (!adding.trim() || !isLive) return;
const next = [{ id: 'new', text: adding, cat: 'メモ', pri: '中', done: false }, ...wishes];
setLiveItems(next.map(w => ({ ...(w._raw || {}), text: w.text, category: w.cat, done: w.done })));
persist(next);
setAdding('');
};
const list = wishes.filter(w => tab === 'todo' ? !w.done : w.done);
const priColor = p => p === '高' ? 'var(--me)' : p === '中' ? 'var(--gold)' : 'var(--ink-faint)';
return (
{/* privacy note */}
このリストは{partnerName}には表示されません。なんでも相談チャットの「小言」からAIが自動で整理しています。
{/* add */}
{/* tabs */}
{[['todo', `未チェック ${wishes.filter(w => !w.done).length}`], ['done', `済み ${wishes.filter(w => w.done).length}`]].map(([id, lab]) => (
))}
{list.map(w => (
{w.text}
{w.cat}
優先 {w.pri}
))}
{list.length === 0 && (
まだありません
なんでも相談チャットで「{partnerName}が◯◯欲しいって言ってた」と話すと、自動でここに溜まります
)}
);
}
Object.assign(window, { AmuletMapScreen, WishListScreen });
})();