/* ============================================================
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 (
);
}
/* ============================================================
データ
============================================================ */
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 (
);
}
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) => (
))}
{/* ============ 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();