/* ============================================================ secret.jsx — ひみつけっしゃ(サポーター / スポンサー募集) Claude-code ダークターミナル調 キャラ: window.PixelPanda(団長) 証システム: rg_tokens から所持枚数を読み込んでパーソナライズ ============================================================ */ /* --- localStorage ヘルパー --- */ const sload = (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } }; const ssave = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }; const sToday = () => new Date().toLocaleDateString("ja-JP"); /* 一回再生のタイプライター(text を一度だけ打つ) */ function OneShot({ text, speed = 45 }) { const [n, setN] = React.useState(0); React.useEffect(() => { if (n >= text.length) return; const t = setTimeout(() => setN(n + 1), speed); return () => clearTimeout(t); }, [n, text]); const done = n >= text.length; return {text.slice(0, n)}{!done && }; } /* ============================================================ 団長パンダのセリフ(タイプライター) ============================================================ */ const BOSS_LINES = [ "ようこそ。ここは表に出ない、しずかな部室みたいなとこ。", "表ではレリゴー楽しく。でも裏は、けっこうガチなんだ。", "支えてほしいのは、ひとり親じゃない人。当事者は、ただ休んでて。", "コーヒー一杯ぶんで、誰かの逃げ場が守れる。", "合言葉さえ言えれば、もう団員だよ。", ]; function Typer({ lines, speed = 48 }) { const [idx, setIdx] = React.useState(() => Math.floor(Math.random() * lines.length)); const [shown, setShown] = React.useState(""); const [phase, setPhase] = React.useState("type"); React.useEffect(() => { const full = lines[idx]; let t; if (phase === "type") { if (shown.length < full.length) t = setTimeout(() => setShown(full.slice(0, shown.length + 1)), speed); else t = setTimeout(() => setPhase("hold"), 2000); } else if (phase === "hold") { t = setTimeout(() => setPhase("next"), 500); } else { let n; do { n = Math.floor(Math.random() * lines.length); } while (n === idx && lines.length > 1); setIdx(n); setShown(""); setPhase("type"); } return () => clearTimeout(t); }, [shown, phase, idx]); return {shown}; } /* 団長パンダ */ function Boss() { const C = window.PixelPanda; return (
{C ? : null}
); } /* ============================================================ データ ============================================================ */ const ROADMAP = [ { ver: "v4.0.5", now: true, label: "サイトと、気持ちの庭", sub: "いまここ。ちいさな置き場所と、証で育つ庭。" }, { ver: "v5", label: "みんなの庭がつながる", sub: "ひとりの庭から、みんなの庭へ。証が合流する。" }, { ver: "v6", label: "ひとり親の仕事をつくる", sub: "支えられる側から、稼げる側へ。小さな経済をつくる。" }, { ver: "v7", label: "オフラインの集会所", sub: "画面の外にも、空いた時間に集える場所をつくる。" }, { ver: "v∞", inf: true, label: "虐待も、孤立もない子ども時代へ", sub: "親がひとりにならない、その先。未来ある子どもたちを守る。" }, ]; const TIPS = [ { amount: 300, label: "コーヒー一杯、ぶん" }, { amount: 500, label: "お昼ごはん、ぶん" }, { amount: 1000, label: "絵本、一冊ぶん" }, { amount: 3000, label: "サーバー代、ちょっと" }, ]; /* コラボの例(企業・店舗向け ・ 上のアイコンパターンに揃える) */ const COLLABS = [ { cmd: "discount", icon: "%", title: "割引・サービス", body: "ひとり親向けの割引やサービスを、一緒に。", subject: "割引・サービスの相談", }, { cmd: "space", icon: "⌂", title: "集会所・イベント", body: "オフラインで集える場やイベントを、一緒に。", subject: "集会所・イベントの相談", }, { cmd: "job", icon: "⊕", title: "仕事をつくる", body: "ひとり親が稼げる仕組みを、一緒に。", subject: "仕事づくりの相談", }, { cmd: "collab", icon: "❖", title: "商品・コンテンツ", body: "商品やコンテンツの、コラボ。", subject: "コラボの相談", }, ]; const WAYS = [ { cmd: "cheer", icon: "♥", title: "応援する", body: "結局全ての起源と根本、人の「気持ち」が人を動かす。", cheer: true, action: "エールを送る", }, { cmd: "idea", icon: "✦", title: "アイデアを出す", body: "「こうしたら面白いかも」を投げてほしい。突飛なほど歓迎。みんなで案を転がしたい。", href: "mailto:hello@405justfound.example?subject=アイデアあります", action: "メールで送る", }, { cmd: "intel", icon: "⌖", title: "情報をくれる", body: "「黙々と頑張ってる人がいる」「こんな事例がある」を教えて。点と点を、つなぎます。", href: "mailto:hello@405justfound.example?subject=情報提供", action: "メールで送る", }, ]; const WHY = [ ["広告も、スポンサー漬けの記事も、", "貼りたくない"], ["数字に追われて、", "発信を盛りたくない"], ["だから、おなじ気持ちの仲間に、", "すこしずつ支えてほしい"], ]; /* ============================================================ タイプライターの順送り制御 登録された全タイパーを縦位置の上から順に1つずつ打鍵。 ・現在の行が終わるまで次は始まらない ・まだ画面に入っていない行は、見えるまで待機 ・スクロールで通り過ぎた(打ち損ねた)行は即完了して次へ(詰まり防止) ============================================================ */ const PREFERS_REDUCE = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; /* 展開内のローカル順送り用コンテキスト(null ならページ本体のスクロール順送り) */ const FoldContext = React.createContext(null); const __items = []; function __rectTop(it) { const el = it.ref && it.ref.current; if (!el) return Infinity; return el.getBoundingClientRect().top + window.scrollY; } let __pumpQueued = false; function __schedulePump() { if (__pumpQueued) return; __pumpQueued = true; requestAnimationFrame(() => { __pumpQueued = false; __pump(); }); } function __pump() { const sorted = __items.slice().sort((a, b) => __rectTop(a) - __rectTop(b)); for (const it of sorted) { if (it.done) continue; if (it.started) break; // 現在打鍵中 → 待つ const el = it.ref.current; const rect = el && el.getBoundingClientRect(); if (rect && rect.bottom < 48) { it.finishInstant(); continue; } // 通り過ぎた → 即完了 if (it.inView) { it.started = true; it.start(); } break; // 先頭の未完了でゲート } } /* 画面タップで、入場タイプライターを全部いっきに完了(スキップ) */ function __skipAllTypers() { let skipped = 0; for (const it of __items) { if (!it.done && it.finishInstant) { it.finishInstant(); skipped++; } } return skipped; } function useTypeGate(ref) { const fold = React.useContext(FoldContext); const [go, setGo] = React.useState(PREFERS_REDUCE); const [instant, setInstant] = React.useState(PREFERS_REDUCE); const item = React.useRef({ ref, idx: null, inView: PREFERS_REDUCE, started: PREFERS_REDUCE, done: PREFERS_REDUCE }).current; React.useEffect(() => { /* --- 展開内:ローカル順送り(スクロール非依存・開いた瞬間に上から打鍵) --- */ if (fold) { item.idx = fold.register(); if (PREFERS_REDUCE || fold.instant) { setInstant(true); setGo(true); } return; } /* --- ページ本体:スクロール順送り --- */ item.start = () => setGo(true); item.finishInstant = () => { item.started = true; item.done = true; setInstant(true); setGo(true); }; __items.push(item); let io; if (!PREFERS_REDUCE && ref.current) { io = new IntersectionObserver((es) => { if (es[0].isIntersecting) { item.inView = true; } __schedulePump(); }, { threshold: 0.6 }); io.observe(ref.current); } window.addEventListener("scroll", __schedulePump, { passive: true }); __schedulePump(); return () => { io && io.disconnect(); window.removeEventListener("scroll", __schedulePump); const i = __items.indexOf(item); if (i >= 0) __items.splice(i, 1); }; }, []); /* fold モード:自分の順番が来たら開始(active を毎レンダリング監視) */ React.useEffect(() => { if (fold && !PREFERS_REDUCE && item.idx != null && fold.active >= item.idx && !item.started) { item.started = true; setGo(true); } }); const markDone = React.useCallback(() => { if (item.done) return; item.done = true; if (fold) fold.advance(item.idx); else __schedulePump(); }, []); return [go, markDone, instant]; } /* タイプライター見出し(順送り制御つき) parts: [{ t:"テキスト", em:true?, b:true?, br:true? }] */ function TypeHeadline({ parts, speed = 52 }) { const total = parts.reduce((s, p) => s + p.t.length, 0); const ref = React.useRef(null); const [go, markDone, instant] = useTypeGate(ref); const [n, setN] = React.useState(PREFERS_REDUCE ? total : 0); React.useEffect(() => { if (instant) { if (n !== total) setN(total); return; } if (!go) return; if (n >= total) { markDone(); return; } const t = setTimeout(() => setN(n + 1), speed); return () => clearTimeout(t); }, [go, n, total, instant]); let used = 0; const out = []; parts.forEach((p, i) => { const remain = Math.max(0, n - used); const show = p.t.slice(0, remain); const partEnd = used + p.t.length; if (show) { if (p.em) out.push({show}); else if (p.b) out.push({show}); else if (p.cls) out.push({show}); else out.push({show}); } if (p.br && n >= partEnd) out.push(
); used = partEnd; }); const typing = go && !instant && n < total; return ( {out} {typing && } ); } /* ブートプロンプト(一番上・最初に打鍵) */ function BootTyper({ segments, speed = 26 }) { const total = segments.reduce((s, p) => s + p.t.length, 0); const ref = React.useRef(null); const [go, markDone, instant] = useTypeGate(ref); const [n, setN] = React.useState(PREFERS_REDUCE ? total : 0); React.useEffect(() => { if (instant) { if (n !== total) setN(total); return; } if (!go) return; if (n >= total) { markDone(); return; } const t = setTimeout(() => setN(n + 1), speed); return () => clearTimeout(t); }, [go, n, total, instant]); let used = 0; const out = []; segments.forEach((p, i) => { const remain = Math.max(0, n - used); const partEnd = used + p.t.length; const show = p.t.slice(0, remain); if (show) out.push(p.cls ? {show} : {show}); if (p.br && n >= partEnd) out.push(
); used = partEnd; }); return {out}; } /* 章扉(順送り:順番が来たら出現+日本語ラベルを打鍵) */ function Chapter({ num, jp, en }) { const ref = React.useRef(null); const [go, markDone, instant] = useTypeGate(ref); const [n, setN] = React.useState(instant ? jp.length : 0); React.useEffect(() => { if (instant) { if (n !== jp.length) setN(jp.length); markDone(); return; } if (!go) return; if (n >= jp.length) { markDone(); return; } const t = setTimeout(() => setN(n + 1), 95); return () => clearTimeout(t); }, [go, n, instant]); const shown = go || instant; const typing = go && !instant && n < jp.length; return (
{num}
{jp.slice(0, n)}{typing && } {en}
); } /* 枠(順番が来たら出現。中のテキスト打鍵の直前に枠を見せる) */ function GateBox({ className = "", children, hold = 55, tag = "div", ...rest }) { const ref = React.useRef(null); const [go, markDone, instant] = useTypeGate(ref); React.useEffect(() => { if (instant) { markDone(); return; } if (go) { const t = setTimeout(markDone, hold); return () => clearTimeout(t); } }, [go, instant]); const shown = go || instant; const Tag = tag; return ( {children} ); } /* 折りたたみ(ターミナル風の展開ボタン・順送り対応) */ function Fold({ cmd, label, children }) { const [open, setOpen] = React.useState(false); const ref = React.useRef(null); const [go, markDone, instant] = useTypeGate(ref); React.useEffect(() => { if (instant) { markDone(); return; } if (go) { const t = setTimeout(markDone, 55); return () => clearTimeout(t); } }, [go, instant]); const shown = go || instant; /* 展開内のローカル順送り(初回だけ打鍵。再展開は即表示) */ const [active, setActive] = React.useState(0); const [instantOpen, setInstantOpen] = React.useState(false); const counter = React.useRef(0); const playedRef = React.useRef(false); const seq = { active, instant: instantOpen, register() { return counter.current++; }, advance(i) { setActive((a) => Math.max(a, i + 1)); }, }; const handleOpen = () => { const wasPlayed = playedRef.current; playedRef.current = true; setInstantOpen(wasPlayed); // 2回目以降は即表示 counter.current = 0; setActive(0); setOpen(true); }; const handleClose = () => { setOpen(false); counter.current = 0; setActive(0); }; return (
{!open && ( )} {open && (
$ {cmd}
{children}
)}
); } /* ============================================================ ページ本体 ============================================================ */ /* ============================================================ インライン問い合わせフォーム(カテゴリ付き・サイト内で送信) 送信内容は rg_contacts に { category, title, nick, text, date } で保存。 将来 WordPress につなぐときは、この配列を送信するだけ。 ============================================================ */ function InlineContactForm({ category, title, action, icon, accent }) { const SERVER = (typeof window !== "undefined" && window.GardenAPI && window.GardenAPI.enabled); // このカテゴリの既存スレッドを読む(rg_contacts に1カテゴリ1スレッド) // 旧形式(msgsなし・textのみ)も読めるよう正規化 const findThread = () => { const raw = sload("rg_contacts", []).find((t) => t.category === category); if (!raw) return null; if (Array.isArray(raw.msgs)) return raw; // 旧形式 → msgs配列へ変換 return { id: raw.id || ("k" + Date.now()), category, title: raw.title || title, nick: raw.nick || "ななしさん", msgs: [{ by: "me", text: raw.text || "", date: raw.date || "" }], }; }; const [thread, setThread] = React.useState(SERVER ? null : findThread); const [open, setOpen] = React.useState(false); const [nick, setNick] = React.useState(() => sload("rg_nick", "")); const [text, setText] = React.useState(""); const [draft, setDraft] = React.useState(""); const [undo, setUndo] = React.useState(false); // 送信直後の取り消し const [confirmDel, setConfirmDel] = React.useState(false); const undoTimer = React.useRef(null); const undoSentText = React.useRef(""); // 接続中:サーバーからこのカテゴリのスレッドを取得 React.useEffect(() => { if (!SERVER) return; window.GardenAPI.contactList().then((res) => { if (res && Array.isArray(res.threads)) { const mine = res.threads.find((t) => t.who === category); if (mine) setThread({ ...mine, category, title }); } }); }, []); const removeThread = () => { // 接続中は表示を連れるだけ(サーバーの記録は残る) if (!SERVER) { const all = sload("rg_contacts", []).filter((t) => t.category !== category); ssave("rg_contacts", all); } setThread(null); setConfirmDel(false); setOpen(false); }; const persist = (updated) => { if (SERVER) { setThread(updated); return; } // サーバー接続時は localStorage に書かない const all = sload("rg_contacts", []); const idx = all.findIndex((t) => t.category === category); let next; if (idx >= 0) { next = all.slice(); next[idx] = updated; } else { next = [updated, ...all]; } ssave("rg_contacts", next); setThread(updated); }; const today = () => new Date().toLocaleDateString("ja-JP", { month: "numeric", day: "numeric" }); // 初回送信 const submit = (e) => { e.preventDefault(); if (!text.trim()) return; const body = text.trim(); const nk = nick.trim() || "ななしさん"; if (nick.trim()) ssave("rg_nick", nick.trim()); if (SERVER) { // 楽観的に表示し、サーバーに送信 const optimistic = { id: "tmp", category, title, nick: nk, msgs: [{ by: "me", text: body, date: today() }] }; setThread(optimistic); window.GardenAPI.contactSend({ category, nick: nk, text: body }).then((res) => { if (res && res.thread) setThread({ ...res.thread, category, title }); }); } else { persist({ id: "k" + Date.now(), category, title, nick: nk, msgs: [{ by: "me", text: body, date: today() }] }); } undoSentText.current = body; // 取り消し用に保持 setText(""); setOpen(false); // 送信直後の取り消し(10秒) setUndo(true); clearTimeout(undoTimer.current); undoTimer.current = setTimeout(() => setUndo(false), 10000); }; const cancelSend = () => { clearTimeout(undoTimer.current); setUndo(false); // 書いた内容をフォームに戻す const txt = undoSentText.current || ""; removeThread(); setText(txt); setOpen(true); }; // 続けて送る const sendFollow = () => { if (!draft.trim() || !thread) return; const body = draft.trim(); if (SERVER) { const tid = (thread.id && String(thread.id).indexOf("srv") === 0) ? thread.id : null; const optimistic = { ...thread, msgs: [...thread.msgs, { by: "me", text: body, date: today() }] }; setThread(optimistic); window.GardenAPI.contactSend({ category, nick: thread.nick, text: body, thread_id: tid }).then((res) => { if (res && res.thread) setThread({ ...res.thread, category, title }); }); } else { persist({ ...thread, msgs: [...thread.msgs, { by: "me", text: body, date: today() }] }); } setDraft(""); }; // ===== スレッドがある(送信済み)===== if (thread) { const hasReply = thread.msgs.some((m) => m.by === "them"); const last = thread.msgs[thread.msgs.length - 1]; return (
{thread.msgs.map((m, i) => ( m.by === "me" ? (
{m.text}{thread.nick}・{m.date}
) : (
{m.text}405 運営・{m.date}
) ))} {!hasReply && last.by === "me" && !undo && (
✓ 受け取りました。お返事は、このスレッドに届きます。
)} {undo && (
送信しました
)}
setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") sendFollow(); }} />
{confirmDel ? (
このやりとりを消す?
) : ( )}
); } // ===== 未送信:ボタン or フォーム ===== if (!open) { return ( setOpen(true)}> {action} {icon} ); } return (
setNick(e.target.value)} />
); } function SecretPage() { // ひみつけっしゃを開いた=運営からの返信を既読に(トップのバッジを消す) React.useEffect(() => { if (window.GardenAPI && window.GardenAPI.enabled && window.GardenAPI.contactList) { window.GardenAPI.contactList().then((res) => { if (!res || !Array.isArray(res.threads)) return; const them = res.threads.reduce((n, th) => n + (th.msgs || []).filter((m) => m.by === "them").length, 0); ssave("rg_contact_seen", them); }); } }, []); const tokenCount = React.useState(() => { const w = sload("rg_tokens", []); return Array.isArray(w) ? w.length : 0; })[0]; const [tipSel, setTipSel] = React.useState(500); const [custom, setCustom] = React.useState(""); const [toast, setToast] = React.useState(null); const [cheerCount, setCheerCount] = React.useState(0); const [cheerTotal, setCheerTotal] = React.useState(() => sload("rg_cheer_total", 0)); const [cheeredToday, setCheeredToday] = React.useState(() => sload("rg_cheer_today", null) === sToday()); // 本番接続中は、みんなのエール総数をサーバーから取得して表示 React.useEffect(() => { if (window.GardenAPI && window.GardenAPI.enabled) { window.GardenAPI.getState().then((s) => { if (s && typeof s.cheers === "number") { ssave("rg_cheer_total", s.cheers); setCheerTotal(s.cheers); } }); } }, []); /* リロード時もページ先頭から入場エフェクトを再生する */ React.useEffect(() => { if ("scrollRestoration" in window.history) window.history.scrollRestoration = "manual"; window.scrollTo(0, 0); }, []); /* 画面ワンタップで入場エフェクトをスキップ(毎回有効) */ React.useEffect(() => { const onTap = (e) => { // ボタン・入力・リンクのタップは通常動作(スキップしない) if (e.target.closest && e.target.closest("button, a, input, textarea, select, label")) return; __skipAllTypers(); }; document.addEventListener("pointerdown", onTap, true); return () => document.removeEventListener("pointerdown", onTap, true); }, []); const sendCheer = (e) => { e.preventDefault(); if (cheeredToday) { showToast("♥ 本日分のエールは送信済みです"); return; } // エールは「気持ちの証」にはならない。みんなの庭の空に星を灯す“もと”になる const nextTotal = sload("rg_cheer_total", 0) + 1; ssave("rg_cheer_total", nextTotal); setCheerTotal(nextTotal); ssave("rg_cheer_today", sToday()); setCheeredToday(true); setCheerCount((c) => c + 1); // 本番接続中は、みんなの合計に加算して本物の総数を表示 if (window.GardenAPI && window.GardenAPI.enabled) { window.GardenAPI.cheer().then((res) => { if (res && typeof res.cheers === "number") { ssave("rg_cheer_total", res.cheers); setCheerTotal(res.cheers); } }); } }; const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(null), 2600); }; const chosen = custom ? parseInt(custom, 10) : tipSel; const selectChip = (amt) => { setTipSel(amt); setCustom(""); }; const sendTip = () => { if (!chosen || chosen <= 0) { showToast("$ 金額を選んでね"); return; } showToast(`¥${chosen.toLocaleString()} を投げ銭 — ありがとう。決済は準備中だよ`); }; return (
{/* ターミナル chrome */}
~/405/secret-society — zsh ◀ exit
{/* ブート / プロンプト */}
{/* タイトル */}

{/* ============ 01 理由 ============ */}

{/* ============ 02 目的 ============ */}

{/* 未来ロードマップ */}
{ROADMAP.map((r, i) => (
{r.ver} {r.now && いまここ}
))}
{/* ============ 03 参加 ============ */}

{WAYS.map((w) => { const inner = (

{w.cheer &&
みんなのエール {cheerTotal}
} {w.cheer ? ( (cheerCount > 0 || cheeredToday) ? ( {cheerCount > 0 ? : 本日分のエールは、送信完了しています。
みんなのエール、いま {cheerTotal}。エール10ごとに、庭の空に星がひとつ灯ります。
}
) : ( {w.action} {w.icon} ) ) : ( )}
); return w.cheer ? {inner} :
{inner}
; })}
{/* コラボ募集(企業・店舗・チーム) */} $ ./collab --partner 企業・お店・チーム

{COLLABS.map((c) => (

))}

{/* 合言葉ブロックは削除 */} {/* 下部 EXIT */} $ exit — トップへもどる
© 2026 405 JUST FOUND — secret society
exit code 0
{toast &&
{toast}
}
); } ReactDOM.createRoot(document.getElementById("root")).render();