;(function(){
const { useState, useEffect, useCallback, useRef } = React;
const SCREENS = {
splash: window.SplashScreen,
auth: window.AuthScreen,
onboarding: window.OnboardingScreen,
pairing: window.PairingScreen,
home: window.HomeScreen,
'home-chat': window.HomeChatScreen,
'event-create': window.EventCreateScreen,
'plan-pending': window.PlanPendingScreen,
'plan-chat': window.PlanChatScreen,
'plan-reveal': window.PlanRevealScreen,
'surprise-hint': window.SurpriseHintScreen,
'amulet-map': window.AmuletMapScreen,
'wish-list': window.WishListScreen,
settings: window.SettingsScreen,
};
/* ════════════════════════════════════════════════════════════════
COLOR THEMES — 全カラーの一括管理(single source of truth)
アプリで使う色トークンはすべてここで定義する。CSS 変数として
documentElement に流し込むので、コンポーネント側は var(--xxx) を
参照するだけ。テーマの追加・変更はこの THEMES 配列だけをいじればよい。
(index.html の :root は初期値/フォールバック。実効値はここが上書き)
──────────────────────────────────────────────────────────────── */
const THEMES = [
{
id: 'snow',
label: 'しろ',
desc: 'ほぼ白。ほんのり紫みのある、明るい基調。',
recommended: true,
swatch: ['#F5F3FA', '#7C6BB4', '#C6A15A'],
vars: {
'--bg': '#F5F3FA', '--paper': '#FCFBFE', '--surface': '#FFFFFF', '--surface-2': '#F0EDF7',
'--ink': '#322E3A', '--ink-soft': '#6D6878', '--ink-faint': '#A8A2B4',
'--line': 'rgba(60,52,74,0.09)', '--line-2': 'rgba(60,52,74,0.15)',
'--me': 'oklch(0.66 0.066 18)', '--partner': 'oklch(0.69 0.052 150)', '--gold': 'oklch(0.74 0.060 75)',
'--amethyst': '#7C6BB4',
'--stage': 'radial-gradient(120% 80% at 50% -10%, #f4f1fb 0%, #efeaf8 55%, #e7e1f3 100%)',
},
},
{
id: 'dusk',
label: 'ゆうやけ',
desc: 'あたたかな夕焼け色。ふたりの時間に。',
swatch: ['#F6EBE6', '#B96D57', '#CB9450'],
vars: {
'--bg': '#F6EBE6', '--paper': '#FDF5F1', '--surface': '#FFF9F6', '--surface-2': '#F6E7DF',
'--ink': '#43352F', '--ink-soft': '#856E64', '--ink-faint': '#BCA79B',
'--line': 'rgba(90,54,44,0.10)', '--line-2': 'rgba(90,54,44,0.16)',
'--me': 'oklch(0.66 0.066 18)', '--partner': 'oklch(0.69 0.052 150)', '--gold': 'oklch(0.76 0.075 62)',
'--amethyst': '#B96D57',
'--stage': 'radial-gradient(120% 80% at 50% -10%, #f7ece6 0%, #f0ddd2 55%, #e9cfc0 100%)',
},
},
{
id: 'linen',
label: 'あたたか',
desc: 'あたたかい麻色。やわらかな空気に。',
swatch: ['#F0EBE1', '#8E7DA6', '#C6A15A'],
vars: {
'--bg': '#F0EBE1', '--paper': '#FBF8F1', '--surface': '#FFFDF8', '--surface-2': '#F5EFE6',
'--ink': '#403A30', '--ink-soft': '#7A7060', '--ink-faint': '#AEA492',
'--line': 'rgba(61,54,44,0.10)', '--line-2': 'rgba(61,54,44,0.16)',
'--me': 'oklch(0.66 0.066 18)', '--partner': 'oklch(0.69 0.052 150)', '--gold': 'oklch(0.74 0.060 75)',
'--amethyst': '#8E7DA6',
'--stage': 'radial-gradient(120% 80% at 50% -10%, #efe8db 0%, #e6ddcd 55%, #ddd3c0 100%)',
},
},
];
const DEFAULT_THEME = THEMES[0].id;
/* テーマ ID を受け取り、全カラートークンを documentElement に反映する。
--amethyst-soft は --amethyst から自動導出。--stage は body 背景へ。 */
function applyTheme(themeId) {
const theme = THEMES.find(t => t.id === themeId) || THEMES[0];
const root = document.documentElement;
Object.entries(theme.vars).forEach(([k, v]) => {
if (k !== '--stage') root.style.setProperty(k, v);
});
root.style.setProperty('--amethyst-soft', `color-mix(in oklch, ${theme.vars['--amethyst']} 22%, ${theme.vars['--paper']})`);
document.body.style.background = theme.vars['--stage'];
return theme;
}
/* ── 画面の「深さ」— 汎用遷移の方向決定用 ──
深くなる遷移は右からスライドイン(push)、浅くなる遷移は左から(pop)、
同じ深さ(下タブの切替など)は従来どおりのクロスフェード(screen-in)。 */
const SCREEN_DEPTH = {
splash: 0, auth: 0, onboarding: 0, pairing: 0,
home: 1, 'amulet-map': 1, 'wish-list': 1, settings: 1,
'home-chat': 2, 'event-create': 2, 'plan-pending': 2, 'plan-reveal': 2,
'surprise-hint': 3, 'plan-chat': 3,
};
/* ── 画面ID → タイトル(GA4 の page_title / ページ表示名に使う)──
GA4 のウェブレポート「ページとスクリーン」は page_view の page_title / page_location で
画面を分離する。SPA なので各遷移で page_view を自前送信し、ここのラベルを載せる。 */
const SCREEN_TITLES = {
splash: 'スプラッシュ',
auth: 'サインイン',
onboarding: '名前の登録',
pairing: 'ペアリング',
home: 'ホーム',
'home-chat': 'ふたりで相談',
'event-create': 'デート作成',
'plan-pending': 'プラン未決定',
'plan-chat': 'プラン相談',
'plan-reveal': 'プラン詳細',
'surprise-hint': 'サプライズ',
'amulet-map': 'Amulet Map',
'wish-list': '小言キーパー',
settings: '設定',
};
/* ── Screen jump list (PC only) ── */
const JUMP_SCREENS = [
{ id: 'splash', label: 'スプラッシュ' },
{ id: 'auth', label: 'サインイン' },
{ id: 'onboarding', label: '名前の登録' },
{ id: 'pairing', label: 'ペアリング' },
{ id: 'home', label: 'ホーム' },
{ id: 'home-chat', label: 'ふたりで相談' },
{ id: 'event-create', label: 'デート作成' },
{ id: 'plan-pending', label: 'プラン未決定' },
{ id: 'plan-reveal', label: 'プラン詳細' },
{ id: 'surprise-hint', label: 'サプライズ' },
{ id: 'amulet-map', label: 'Amulet Map' },
{ id: 'wish-list', label: '小言キーパー' },
{ id: 'settings', label: '設定' },
];
/* ── キーボード追従: visualViewport に合わせてアプリ全体の高さを縮める ──
iOS Safari はキーボード表示時にページ全体を上へパンし、ヘッダが画面外に隠れてしまう。
代わりにルートコンテナの高さを visualViewport.height に合わせ、パンは scrollTo(0,0) と
translateY(offsetTop) で打ち消す。→ ヘッダ・入力バーは残り、スクロール領域だけが縮む。
キーボードの高さは --kbh として配下の CSS から参照できる(入力バーの下パディング調整用)。 */
function useVisualViewport(active) {
const [vp, setVp] = useState({ h: window.innerHeight, top: 0, kb: 0 });
useEffect(() => {
const vv = window.visualViewport;
if (!active || !vv) return;
const update = () => {
window.scrollTo(0, 0);
setVp({
h: Math.round(vv.height),
top: Math.round(vv.offsetTop),
kb: Math.max(0, Math.round(window.innerHeight - vv.height)),
});
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
window.addEventListener('resize', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
};
}, [active]);
return vp;
}
/* ── Toast ── */
function Toast({ toast }) {
if (!toast) return null;
return (
{toast}
);
}
function App() {
const [screen, setScreen] = useState('splash');
const [params, setParams] = useState({});
const [scale, setScale] = useState(1);
const [isMobile, setIsMobile] = useState(() => window.innerWidth <= 480);
const [debugOpen, setDebugOpen] = useState(false); // PC の画面遷移debugメニューは既定で閉じる
const [theme, setThemeState] = useState(
() => localStorage.getItem('amulet_theme') || DEFAULT_THEME
);
const [toast, setToast] = useState(null);
/* ── live data (Firebase) ── */
const [authUser, setAuthUser] = useState(undefined); // undefined=判定中 / null=未ログイン
const [userDoc, setUserDoc] = useState(null);
const [partner, setPartner] = useState(null);
const [coupleDoc, setCoupleDoc] = useState(null); // couples/{id}(since 等)
const [events, setEvents] = useState(null);
const bootRouted = useRef(false);
const autoLoginTried = useRef(false);
const vp = useVisualViewport(isMobile);
/* チャット遷移用: タップ(ユーザージェスチャ)内で不可視 input へ先にフォーカスして
キーボードを起動しておく。iOS はジェスチャ外の focus() ではキーボードを出さないが、
すでに入力要素にフォーカスがある状態での「フォーカスの引き継ぎ」は許可されるため、
遷移先の textarea が focus() すればキーボードは開いたまま移る。 */
const ghostInputRef = useRef(null);
window.__prefocus = () => {
const el = ghostInputRef.current;
if (el) el.focus({ preventScroll: true });
};
// 遷移方向(push/pop/フェード)は画面の深さ差で決める
const [navAnim, setNavAnim] = useState('screen-in');
const screenRef = useRef('splash');
/* ── 履歴スタック ────────────────────────────────────────────────
params には Firestore 由来のオブジェクト(ev.raw の Timestamp 等)が載るため
history.state には入れられない(structured clone が失敗する)。state には
インデックスと画面 ID だけを持たせ、params 本体は navStack がメモリで保持する。
go() は既定で pushState。ただし「直前のエントリと同じ画面へ戻る」遷移だけは
history.back() に読み替えるので、TopBar の onBack や下タブの往復で履歴が
積み増されることはない。認証フローのように戻れてはいけない遷移は
go(id, params, { replace: true }) を使う。 */
const navStack = useRef([{ id: 'splash', params: {} }]);
const navIdx = useRef(0);
const popping = useRef(false); // 連打で history.back() が二重に走るのを防ぐ
const histState = (i, id) => ({ __amulet: { i, id } });
// 履歴を触らずに画面だけ切り替える(go / popstate 共通の後処理)
const commit = useCallback((id, p) => {
const from = SCREEN_DEPTH[screenRef.current] ?? 0;
const to = SCREEN_DEPTH[id] ?? 0;
setNavAnim(to > from ? 'nav-push' : to < from ? 'nav-pop' : 'screen-in');
screenRef.current = id;
setScreen(id); setParams(p);
/* GA4: SPA なので画面遷移ごとに page_view を明示送信する。
画面別に分離させるため page_title(日本語ラベル)と page_location(#id 付き URL)を
毎回渡す。ハッシュはこの時点で go()/popstate 側が更新済み。
screen_name も残す(アプリ計測やカスタムディメンション向けの保険)。 */
if (window.track) {
const title = SCREEN_TITLES[id] || id;
window.track('page_view', {
page_title: title,
page_location: location.origin + location.pathname + '#' + id,
screen_name: id,
});
}
}, []);
const go = useCallback((id, p = {}, opts = {}) => {
if (popping.current) return; // back() の popstate 待ち。二重遷移させない
// 同じ画面への再遷移は params の更新だけ(下タブの現在地タップなど)
if (id === screenRef.current && !opts.replace) { setParams(p); navStack.current[navIdx.current].params = p; return; }
const prev = navStack.current[navIdx.current - 1];
if (!opts.replace && prev && prev.id === id) {
// 「戻る」に相当する遷移 → 履歴を積まず、ブラウザの戻るに委ねる。
// 呼び出し側が新しい params を渡していれば戻り先の内容を最新に差し替える。
if (p && Object.keys(p).length) prev.params = p;
popping.current = true;
history.back();
return;
}
if (opts.replace) {
navStack.current[navIdx.current] = { id, params: p };
try { history.replaceState(histState(navIdx.current, id), '', '#' + id); } catch (e) {}
} else {
navStack.current = navStack.current.slice(0, navIdx.current + 1);
navStack.current.push({ id, params: p });
navIdx.current += 1;
try { history.pushState(histState(navIdx.current, id), '', '#' + id); } catch (e) {}
}
commit(id, p);
}, [commit]);
window.__go = go;
/* 明示的に一段戻る。履歴の先頭(=アプリ起動直後の画面)ならホームへ寄せる。 */
const back = useCallback(() => {
if (popping.current) return;
if (navIdx.current > 0) { popping.current = true; history.back(); }
else go('home', {}, { replace: true });
}, [go]);
window.__back = back;
/* ── 履歴の初期化 + 戻る/進むの受け口 ──
リロードすると navStack は失われる一方、ブラウザ側の履歴エントリは残る。
復元できない位置へ飛ばされたらホームへ寄せる(params 不在で画面が壊れるため)。 */
useEffect(() => {
const first = screenRef.current;
try { history.replaceState(histState(0, first), '', '#' + first); } catch (e) {}
// GA4: 初回表示(通常は splash)の page_view。以降の遷移は commit() が送る。
// これが無いとセッションのランディングページが記録されない。
if (window.track) {
window.track('page_view', {
page_title: SCREEN_TITLES[first] || first,
page_location: location.origin + location.pathname + '#' + first,
screen_name: first,
});
}
const onPop = (e) => {
popping.current = false;
const st = e.state && e.state.__amulet;
const entry = st && navStack.current[st.i];
if (!entry || entry.id !== st.id) {
// 復元不能。スタックをホーム1枚に畳み直して整合を取る
navStack.current = [{ id: 'home', params: {} }];
navIdx.current = 0;
try { history.replaceState(histState(0, 'home'), '', '#home'); } catch (err) {}
commit('home', {});
return;
}
navIdx.current = st.i;
commit(entry.id, entry.params);
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, [commit]);
const showToast = useCallback((msg) => {
setToast(msg);
clearTimeout(showToast._t);
showToast._t = setTimeout(() => setToast(null), 2800);
}, []);
window.__toast = showToast;
const setTheme = useCallback((id) => {
setThemeState(id);
localStorage.setItem('amulet_theme', id);
}, []);
window.__setTheme = setTheme;
window.__themes = THEMES;
useEffect(() => { applyTheme(theme); }, [theme]);
/* ── boot: 認証監視 ── */
useEffect(() => {
const unsubAuth = window.Amulet.onAuth(async (u) => {
setAuthUser(u);
if (u) await window.Amulet.ensureUserDoc().catch(console.error);
});
return () => unsubAuth();
}, []);
/* ── PC プレビュー: 未ログインならテストユーザーで自動サインイン ──
(ダミーデータの代わりに、test@example.com の実データを表示する)
既にサインイン中なら何もしない。モバイルは通常フロー。 */
useEffect(() => {
if (authUser !== null) return; // 判定中(undefined) / ログイン済みは対象外
if (isMobile) return; // PC レイアウトのみ
if (autoLoginTried.current) return;
autoLoginTried.current = true;
(async () => {
try {
await window.Amulet.requestEmailCode('test@example.com'); // メールは飛ばない
await window.Amulet.verifyEmailCode('test@example.com', '000000');
// onAuth が発火して以降の購読が走る
} catch (e) { console.error('auto test-login failed:', e); }
})();
}, [authUser, isMobile]);
/* ── userDoc 購読(ペアリング成立の双方向反映もここ)── */
useEffect(() => {
if (!authUser) { setUserDoc(null); setPartner(null); setCoupleDoc(null); setEvents(null); return; }
return window.Amulet.listenUser(setUserDoc);
}, [authUser]);
/* ── couple 確定後: partner / couple(since) / events 購読 ── */
useEffect(() => {
const coupleId = userDoc?.coupleId;
if (!coupleId) { setPartner(null); setCoupleDoc(null); setEvents(null); return; }
window.Amulet.fetchPartner(coupleId).then(setPartner).catch(console.error);
const unsubCouple = window.Amulet.listenCouple(coupleId, setCoupleDoc);
const unsubEvents = window.Amulet.listenEvents(coupleId, setEvents);
return () => { unsubCouple(); unsubEvents(); };
}, [userDoc?.coupleId]);
/* ── 起動時の自動ルーティング(1回だけ。以降は手動ナビ優先)── */
useEffect(() => {
if (authUser === undefined || bootRouted.current) return;
if (!authUser) { bootRouted.current = true; return; } // splash のまま
if (!userDoc) return; // userDoc 読み込み待ち
bootRouted.current = true;
// 起動直後の行き先はスプラッシュを置き換える(戻ってもスプラッシュに帰らせない)
if (!userDoc.name) go('onboarding', {}, { replace: true });
else if (!userDoc.coupleId) go('pairing', {}, { replace: true });
else go('home', {}, { replace: true });
}, [authUser, userDoc]);
useEffect(() => {
const PHONE_W = 402, PHONE_H = 874;
const update = () => {
const vw = window.innerWidth, vh = window.innerHeight;
const mobile = vw <= 480;
setIsMobile(mobile);
if (!mobile) {
/* reserve 左:QR + 右:debugメニュー + gap の横幅 */
setScale(Math.min((vw - 540) / PHONE_W, (vh - 32) / PHONE_H, 1.15));
}
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
const live = {
user: authUser || null,
userDoc,
coupleId: userDoc?.coupleId || null,
partner,
couple: coupleDoc,
since: coupleDoc?.since || null, // 付き合った日 {year,month,day}
events,
signedIn: !!authUser && !!userDoc?.coupleId,
};
const ctx = {
go, back, screen, params, live, toast: showToast,
t: { motif: 'map', progress: 0.72, detail: 'しっかり' },
theme, setTheme, themes: THEMES,
};
const Cur = SCREENS[screen] || SCREENS.splash;
// shared-element 遷移(params.hero)のときは既定の遷移を切り、遷移先が自前で演出する
const screenAnim = params.hero ? undefined : navAnim;
/* ── Mobile: full-viewport, no device frame, no jump panel ── */
if (isMobile) {
return (
);
}
/* ── PC: device frame + jump panel to the right ── */
return (
{/* QR (left) — スマホで開く。背景透過PNGなのでリネン地に溶け込ませる(枠・背景なし) */}
スマホで開く
{/* Phone frame */}
{/* 画面遷移debugメニュー(ハッカソン審査用)。既定は閉じており、開くボタンで展開する。 */}
{/* ヘッダ+開閉ボタン */}
画面遷移debugメニュー
(ハッカソン審査用に出しています。)
{debugOpen && (
<>
{/* 注意書き(PCプレビューは未ログインだと自動でテストユーザーにサインインする) */}
未ログイン状態でdebug用遷移を行うと、テストユーザー(test@example.com)として扱われます。
SCREENS
{JUMP_SCREENS.map(s => {
const active = screen === s.id;
return (
);
})}
>
)}
);
}
ReactDOM.createRoot(document.getElementById('root')).render();
})();