/*__IIFE_WRAPPED__*/ ;(function(){ // firebase-client.jsx — Firebase SDK初期化 + メール認証コード認証 + callable/リスナー // // 認証はメールアドレス+6桁の認証コード。パスワードは一切扱わない。 // コード送信・検証はサーバー (requestEmailCode/verifyEmailCode) で行い、 // 検証成功時に返るカスタムトークンで signInWithCustomToken する。 // API呼び出しはすべて httpsCallable(トークンは SDK が自動付与・サーバー側で検証)。 // 別環境へデプロイする場合はこの FIREBASE_CONFIG だけ差し替える: // firebase apps:sdkconfig web --project const FIREBASE_CONFIG = { apiKey: "AIzaSyDx0MsDYa1EF6zZRYWt1Rr03hjLr1CbW38", authDomain: "fair-torus-498607-e7.firebaseapp.com", projectId: "fair-torus-498607-e7", storageBucket: "fair-torus-498607-e7.firebasestorage.app", messagingSenderId: "951564206213", appId: "1:951564206213:web:a75ebd8d8438ca8c3aa9ec", }; const REGION = "asia-northeast1"; const EMAIL_KEY = "amulet_email_for_signin"; // コード入力画面でのメアド保持用 // ── 初期化(?emulator=1 か localhost でエミュレータ接続)── let _app, _auth, _db, _fns; function fb() { if (_app) return { auth: _auth, db: _db, fns: _fns }; _app = firebase.initializeApp(FIREBASE_CONFIG); _auth = firebase.auth(); _db = firebase.firestore(); _fns = firebase.app().functions(REGION); const wantEmu = new URLSearchParams(location.search).has("emulator"); if (wantEmu) { _auth.useEmulator("http://localhost:9099"); _db.useEmulator("localhost", 8080); _fns.useEmulator("localhost", 5001); } return { auth: _auth, db: _db, fns: _fns }; } function call(name, data) { // GA4計装: どの callable を呼んだかだけ送る(引数の中身=本文/PIIは絶対に送らない) window.track && window.track('api_call', { api_name: name }); return fb().fns.httpsCallable(name)(data ?? {}).then(r => r.data); } /* ════════ 認証(メール認証コード)════════ */ // メアド宛に6桁コードを送る(test@example.com はサーバー側でメールを送らず 000000 固定) async function requestEmailCode(email) { await call("requestEmailCode", { email }); localStorage.setItem(EMAIL_KEY, email); } // コードを検証し、返ったカスタムトークンでサインインする async function verifyEmailCode(email, code) { const { auth } = fb(); const { token } = await call("verifyEmailCode", { email, code }); await auth.signInWithCustomToken(token); localStorage.removeItem(EMAIL_KEY); } function onAuth(cb) { return fb().auth.onAuthStateChanged(cb); } async function signOut() { localStorage.removeItem(EMAIL_KEY); await fb().auth.signOut(); } /* ════════ users ════════ */ // 初回サインイン時の users/{uid} 作成(既存なら何もしない) async function ensureUserDoc() { const { auth, db } = fb(); const u = auth.currentUser; if (!u) return; const ref = db.doc(`users/${u.uid}`); const snap = await ref.get(); if (!snap.exists) { await ref.set({ email: u.email ?? "", name: "", coupleId: null, createdAt: firebase.firestore.FieldValue.serverTimestamp() }); } } function saveName(name) { const u = fb().auth.currentUser; return fb().db.doc(`users/${u.uid}`).update({ name, updatedAt: firebase.firestore.FieldValue.serverTimestamp(), }); } // プロフィールの一部だけ更新(最寄駅など)。rules で許可されたキーのみ通る。 function saveProfile(patch) { const u = fb().auth.currentUser; return fb().db.doc(`users/${u.uid}`).update({ ...patch, updatedAt: firebase.firestore.FieldValue.serverTimestamp(), }); } function listenUser(cb) { const u = fb().auth.currentUser; if (!u) return () => {}; return fb().db.doc(`users/${u.uid}`).onSnapshot( s => cb(s.exists ? { uid: u.uid, ...s.data() } : null), e => console.error("listenUser:", e), ); } async function fetchPartner(coupleId) { const { auth, db } = fb(); const couple = await db.doc(`couples/${coupleId}`).get(); const members = couple.get("members") || []; const partnerUid = members.find(m => m !== auth.currentUser.uid); if (!partnerUid) return null; const p = await db.doc(`users/${partnerUid}`).get(); return { uid: partnerUid, name: p.get("name") || "パートナー", nearestStation: p.get("nearestStation") || "", members }; } // couple ドキュメント(since=付き合った日 等)を購読。設定変更も即反映される。 function listenCouple(coupleId, cb) { return fb().db.doc(`couples/${coupleId}`).onSnapshot( s => cb(s.exists ? { id: s.id, ...s.data() } : null), e => console.error("listenCouple:", e)); } // 付き合った日を保存(本人カップルのみ・rules で since/anniversaries のみ許可) function setAnniversary(coupleId, since) { return fb().db.doc(`couples/${coupleId}`).set({ since }, { merge: true }); } /* ════════ events ════════ */ function listenEvents(coupleId, cb) { return fb().db.collection(`couples/${coupleId}/events`) .orderBy("createdAt", "desc").limit(20) .onSnapshot( qs => cb(qs.docs.map(d => ({ id: d.id, ...d.data() }))), e => console.error("listenEvents:", e), ); } function listenEvent(coupleId, eventId, cb) { return fb().db.doc(`couples/${coupleId}/events/${eventId}`) .onSnapshot(s => cb(s.exists ? { id: s.id, ...s.data() } : null), e => console.error("listenEvent:", e)); } async function createEvent(coupleId, data) { const u = fb().auth.currentUser; const ref = await fb().db.collection(`couples/${coupleId}/events`).add({ ...data, planStatus: "none", genCount: 0, createdBy: u.uid, createdAt: firebase.firestore.FieldValue.serverTimestamp(), }); return ref.id; } // 基本情報(タイトル/日付/エリア/時間帯)の後追い編集。 // planStatus / genCount / inputStats / lastGenRequestedBy はサーバー管理のため // firestore.rules 側で更新が拒否される(それら以外のフィールドだけ渡すこと)。 async function updateEvent(coupleId, eventId, patch) { // undefined は Firestore が受け付けないので落とす const clean = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)); await fb().db.doc(`couples/${coupleId}/events/${eventId}`).update(clean); } // デート(イベント)を削除。firestore.rules で isMember なら delete 許可。 // サブコレクション(inputs/plan/plans/...)はクライアントでは消せないが、 // 一覧・詳細から辿れなくなるので実用上は消えたのと同じ。 function deleteEvent(coupleId, eventId) { return fb().db.doc(`couples/${coupleId}/events/${eventId}`).delete(); } function listenPlan(coupleId, eventId, cb) { return fb().db.doc(`couples/${coupleId}/events/${eventId}/plan/current`) .onSnapshot(s => cb(s.exists ? s.data() : null), e => console.error("listenPlan:", e)); } // 新: バージョン管理されたプラン JSON。最新版(version 最大)を購読する。 // 書き換え(planChat)で新バージョンが生まれると自動で差し替わる。 // 互換: plans/ がまだ無い旧データは plan/current にフォールバックする。 function listenLatestPlan(coupleId, eventId, cb) { const base = `couples/${coupleId}/events/${eventId}`; return fb().db.collection(`${base}/plans`) .orderBy("version", "desc").limit(1) .onSnapshot(qs => { if (!qs.empty) { cb(qs.docs[0].data()); return; } fb().db.doc(`${base}/plan/current`).get() .then(s => cb(s.exists ? s.data() : null)) .catch(() => cb(null)); }, e => console.error("listenLatestPlan:", e)); } async function getMyInput(coupleId, eventId) { const u = fb().auth.currentUser; const s = await fb().db.doc(`couples/${coupleId}/events/${eventId}/inputs/${u.uid}`).get(); return s.exists ? s.data() : null; } // ③ プラン相談の本人スレッド(相手には見えない) async function getMyPlanChat(coupleId, eventId) { const u = fb().auth.currentUser; const s = await fb().db.doc(`couples/${coupleId}/events/${eventId}/planChat/${u.uid}`).get(); return s.exists ? s.data() : null; } async function getSurprise(coupleId, eventId) { const u = fb().auth.currentUser; const s = await fb().db.doc(`couples/${coupleId}/events/${eventId}/surprises/${u.uid}`).get(); return s.exists ? s.data() : null; } /* ════════ pins / wishlist ════════ */ function listenPins(coupleId, cb) { return fb().db.collection(`couples/${coupleId}/pins`) .orderBy("createdAt", "desc").limit(50) .onSnapshot(qs => cb(qs.docs.map(d => ({ id: d.id, ...d.data() }))), e => console.error("listenPins:", e)); } function addPin(coupleId, pin) { const u = fb().auth.currentUser; // undefined は Firestore が受け付けないので落とす(photoUrl/placeId 等の任意フィールド対策) const clean = Object.fromEntries(Object.entries(pin).filter(([, v]) => v !== undefined)); return fb().db.collection(`couples/${coupleId}/pins`).add({ ...clean, addedBy: u.uid, createdAt: firebase.firestore.FieldValue.serverTimestamp(), }).then(ref => { window.track && window.track('pin_add'); return ref; }); } function deletePin(coupleId, pinId) { return fb().db.doc(`couples/${coupleId}/pins/${pinId}`).delete(); } function listenWishlist(coupleId, cb) { const u = fb().auth.currentUser; return fb().db.doc(`couples/${coupleId}/wishlists/${u.uid}`) .onSnapshot(s => cb(s.exists ? (s.get("items") || []) : []), e => console.error("listenWishlist:", e)); } function saveWishlist(coupleId, items) { const u = fb().auth.currentUser; return fb().db.doc(`couples/${coupleId}/wishlists/${u.uid}`).set({ items, updatedAt: firebase.firestore.FieldValue.serverTimestamp(), }, { merge: true }); } /* ════════ 画像ユーティリティ ════════ */ // File → 縮小済み base64(長辺1024px / JPEG)。callable のペイロード上限対策。 function fileToBase64(file, maxEdge = 1024) { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { const scale = Math.min(1, maxEdge / Math.max(img.width, img.height)); const canvas = document.createElement("canvas"); canvas.width = Math.round(img.width * scale); canvas.height = Math.round(img.height * scale); canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(url); const dataUrl = canvas.toDataURL("image/jpeg", 0.84); resolve({ base64: dataUrl.split(",")[1], mimeType: "image/jpeg" }); }; img.onerror = reject; img.src = url; }); } /* ════════ エラーメッセージ ════════ */ function friendlyError(e) { const msg = e?.message || ""; // HttpsError は日本語メッセージをそのまま出す。SDK内部エラーは丸める。 if (e?.code?.startsWith?.("functions/") && msg && !/INTERNAL|internal/.test(msg)) return msg; if (/network|fetch|Failed to/i.test(msg)) return "通信に失敗しました。電波のよいところでもう一度。"; return "うまくいきませんでした。もう一度試してください。"; } /* ════════ 公開API ════════ */ const Amulet = { // auth requestEmailCode, verifyEmailCode, onAuth, signOut, currentUser: () => fb().auth.currentUser, // users / couple ensureUserDoc, saveName, saveProfile, listenUser, fetchPartner, listenCouple, setAnniversary, // pairing (callable) createInvite: () => call("createInvite"), redeemInvite: (code) => call("redeemInvite", { code }), // events listenEvents, listenEvent, createEvent, updateEvent, deleteEvent, listenPlan, listenLatestPlan, getMyInput, getMyPlanChat, getSurprise, // chat / plan (callable) sendChat: (p) => call("sendChat", p), // ① 本音・相談役(counsel) planChat: (p) => call("planChat", p), // ③ プラン相談(会話。案が溜まると承認待ちに) applyPlanProposal: (eventId) => call("applyPlanProposal", { eventId }), // ③ 溜まった案を承認して作り直す generatePlan: (eventId, adjustment, requiredSpots) => call("generatePlan", { eventId, adjustment, requiredSpots }), detectSpot: (imageBase64, mimeType, chatContext, textQuery) => call("detectSpot", { ...(imageBase64 ? { imageBase64, mimeType } : {}), chatContext, ...(textQuery ? { textQuery } : {}), }), // pins / wishlist listenPins, addPin, deletePin, listenWishlist, saveWishlist, // utils fileToBase64, friendlyError, }; Object.assign(window, { Amulet }); })();