レシピ · 8手 · 1プロンプト実装

ライブ投票(poll/Q&A)を追加する

既存イベント app に主催主導のライブ poll を配線し、参加者画面(ポーリング投票)と admin 主催コンソール(host key・開始/停止・ライブ集計)を動かす

前提: new-event解説 ch.28module: ライブ投票

このレシピは @event/feedback の live エンジン(実装済み・章28)をイベント app に配線する完全手順。 対象 app を apps/<app> とする(例では apps/starter)。上から順に実行すれば完了する。

状態はすべて KV — 本番は Upstash(KV_REST_API_URL/TOKEN)、未設定の dev は kv() の in-memory shim で そのまま動く(Next dev は単一プロセスなのでリクエスト跨ぎで保持される)。DB / WebSocket は不要。 リアルタイムは参加者側の 2 秒ポーリング + 3 系統の rev(viewRev / promptRev / results.rev)観測で実現する。

1. 依存を追加

apps/<app>/package.json の dependencies に追加(starter には既に入っている):

"@event/feedback": "*",
"@event/core": "*"

apps/<app>/next.config.tstranspilePackages 配列に "@event/feedback""@event/core" を足す。

2. 機能を有効化

apps/<app>/src/event.config.ts の features で:

live: true,

FEATURES に live descriptor を追加(starter には既に入っている):

{
  key: "live",
  label: "ライブ投票",
  description: "主催主導のライブ poll — KV ポーリング + derived host key(@event/feedback live)",
  nav: [
    { id: "live-host", group: "フィードバック", label: "ライブ投票", href: "/admin/live", code: "LIVE", order: 2 },
  ],
  env: [
    { name: "LIVE_HOST_SECRET", required: false, description: "host key の導出秘密(無ければ ADMIN_PASSWORD から導出)" },
    { name: "KV_REST_API_URL", required: false, description: "Upstash KV REST URL(未設定は dev in-memory shim)" },
    { name: "KV_REST_API_TOKEN", required: false, description: "Upstash KV REST token(未設定は dev in-memory shim)" },
  ],
  manualChapters: [28],
},

3. live の配線ファイルを作る

apps/<app>/src/lib/live.ts:

import { createLiveStore, hostKeyFor, verifyHostKey } from "@event/feedback";
import { kv } from "@event/core";

/**
 * live poll の配線点。状態はすべて KV — 本番は Upstash(KV_REST_API_*)、
 * 未設定の dev は kv() の in-memory shim(Next dev は単一プロセスなので
 * リクエスト跨ぎで保持される)。DB / WebSocket は不要。
 */
export const liveStore = createLiveStore({ kv: kv() });

/**
 * host key(主催パスワード)の安全な導出。導出秘密
 * (LIVE_HOST_SECRET ?? ADMIN_PASSWORD)が無い環境では hostKeyFor が throw
 * するので、null に落として呼び出し側が 503 / 案内表示に倒せるようにする。
 */
export function tryHostKeyFor(unitId: string): string | null {
  try {
    return hostKeyFor(unitId);
  } catch {
    return null;
  }
}

/**
 * host key の照合。戻り値 3 値:
 *  - true  … 一致(定数時間比較)
 *  - false … 不一致
 *  - null  … 導出秘密が未設定で照合不能(= 503 にする)
 */
export function tryVerifyHostKey(unitId: string, key: string): boolean | null {
  try {
    return verifyHostKey(unitId, key);
  } catch {
    return null;
  }
}

export { hostKeyFor, verifyHostKey };

createLiveStore(options) の必須は kv のみ。keyPrefix(既定 'live:')と views (既定 ['survey','qa']・先頭が既定 view)は省略でよい。)

4. 公開 API — 参加者のポーリングと投票

apps/<app>/src/app/api/live/[unitId]/route.ts:

import { NextResponse } from "next/server";
import { voterHashOf } from "@event/feedback";
import { features } from "@/event.config";
import { liveStore } from "@/lib/live";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
 * 公開 live API(認証不要 — 参加者のポーリング用)。
 *  - GET  → 現在の view + active お題 + 集計。参加者は 2 秒間隔でポーリングし、
 *    viewRev / promptRev / results.rev の変化で差分描画する(WebSocket 不要)。
 *  - POST → 投票 { promptId, optionId }。voterHashOf(UA+IP+promptId) で 1 人 1 票
 *    (dedup は KV の SADD。同一端末の連打は 1 票に収束する)。
 */

type Ctx = { params: Promise<{ unitId: string }> };

export async function GET(_req: Request, ctx: Ctx) {
  if (!features.isEnabled("live")) {
    return NextResponse.json({ ok: false, error: "feature_disabled" }, { status: 404 });
  }
  const { unitId } = await ctx.params;
  const [{ view, rev: viewRev }, state] = await Promise.all([
    liveStore.getLiveView(unitId),
    liveStore.getLiveState(unitId),
  ]);
  // state = { prompt, promptRev, results }(LiveState)に view 系を足して 1 応答に。
  return NextResponse.json({ ok: true, view, viewRev, ...state });
}

type VoteBody = { promptId?: string; optionId?: string };

export async function POST(req: Request, ctx: Ctx) {
  if (!features.isEnabled("live")) {
    return NextResponse.json({ ok: false, error: "feature_disabled" }, { status: 404 });
  }
  const { unitId } = await ctx.params;

  let body: VoteBody;
  try {
    body = (await req.json()) as VoteBody;
  } catch {
    return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 });
  }
  const promptId = typeof body.promptId === "string" ? body.promptId : "";
  const optionId = typeof body.optionId === "string" ? body.optionId : "";
  if (!promptId || !optionId) {
    return NextResponse.json({ ok: false, error: "bad_request" }, { status: 400 });
  }

  const ua = req.headers.get("user-agent") ?? "";
  const ip =
    req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
    req.headers.get("x-real-ip") ||
    "local";

  // respond は prompt.id 不一致(既に切替済み)を { ok:true, stale:true } で黙って無視する。
  const result = await liveStore.respond(
    unitId,
    promptId,
    { optionId },
    voterHashOf(ua, ip, promptId),
  );
  if (!result.ok) {
    return NextResponse.json({ ok: false, error: "invalid_option" }, { status: 400 });
  }
  return NextResponse.json(result);
}

respond(unitId, promptId, { optionId }, voterHash) の戻りは { ok, deduped?, stale? }voterHashOf(ua, ip, pid) は promptId を混ぜるので、次のお題では同じ人がまた投票できる。)

5. host API — お題の開始/停止と view 切替

apps/<app>/src/app/api/live/[unitId]/host/route.ts:

import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import type { PromptActionBody } from "@event/feedback";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { liveStore, tryHostKeyFor, tryVerifyHostKey } from "@/lib/live";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
 * 主催(host)API。
 *  - GET  → host key の取得(adminAuth 必須)。admin が登壇者/モデレーターに配布する。
 *  - POST → host key 照合(verifyHostKey・定数時間比較)の上で
 *    お題の開始/停止(applyPromptAction)と view 切替(setLiveView)。
 *    admin cookie では通さない — admin UI も登壇者と同じ host key 経路を通す。
 *
 * host key は LIVE_HOST_SECRET ?? ADMIN_PASSWORD から決定的に導出(保存しない)。
 * どちらも無い環境では 503 を返して機能を安全に無効化する。
 */

type Ctx = { params: Promise<{ unitId: string }> };

export async function GET(_req: Request, ctx: Ctx) {
  if (!features.isEnabled("live")) {
    return NextResponse.json({ ok: false, error: "feature_disabled" }, { status: 404 });
  }
  const cookie = (await cookies()).get(adminAuth.cookieName)?.value;
  if (!adminAuth.isAuthedFromValue(cookie)) {
    return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
  }
  const { unitId } = await ctx.params;
  const hostKey = tryHostKeyFor(unitId);
  if (!hostKey) {
    return NextResponse.json({ ok: false, error: "host_key_unavailable" }, { status: 503 });
  }
  return NextResponse.json({ ok: true, hostKey });
}

/** PromptActionBody(action/question/options)+ 認可 key + view 切替用 view。 */
type HostBody = PromptActionBody & { key?: string; view?: string };

export async function POST(req: Request, ctx: Ctx) {
  if (!features.isEnabled("live")) {
    return NextResponse.json({ ok: false, error: "feature_disabled" }, { status: 404 });
  }
  const { unitId } = await ctx.params;

  let body: HostBody;
  try {
    body = (await req.json()) as HostBody;
  } catch {
    return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 });
  }

  const verified = tryVerifyHostKey(unitId, typeof body.key === "string" ? body.key : "");
  if (verified === null) {
    return NextResponse.json({ ok: false, error: "host_key_unavailable" }, { status: 503 });
  }
  if (!verified) {
    return NextResponse.json({ ok: false, error: "forbidden" }, { status: 403 });
  }

  // view 切替は applyPromptAction の前に分岐(それ以外の action は start/stop として処理)。
  if (body.action === "view") {
    const v = await liveStore.setLiveView(unitId, typeof body.view === "string" ? body.view : "");
    return NextResponse.json({ ok: true, ...v });
  }

  // applyPromptAction: action==='stop' で終了、それ以外は question + options(2枠以上) で開始。
  const res = await liveStore.applyPromptAction(unitId, body);
  if (!res.ok) {
    return NextResponse.json({ ok: false, error: res.error }, { status: res.status });
  }
  return NextResponse.json(res);
}

applyPromptAction(unitId, body){ ok:true, state: LiveState }{ ok:false, error, status } を返す。question は 140 字 trim、options は 2〜4 枠・ 空ラベルは A/B/C/D にフォールバック。認可は呼び出し側で済ませる前提なので必ず verify を先に。)

6. 参加者ページ(公開・ポーリング投票)

apps/<app>/src/app/live/[unitId]/page.tsx(server・認証不要・feature-gate):

import { notFound } from "next/navigation";
import { features } from "@/event.config";
import { LiveRoom } from "./LiveRoom";

export const dynamic = "force-dynamic";

/**
 * /live/[unitId] — 参加者のライブ投票画面(認証不要・feature-gate のみ)。
 * QR / リンクで配布する。実体は LiveRoom(2 秒ポーリング)に委譲する。
 */
export default async function LivePage({ params }: { params: Promise<{ unitId: string }> }) {
  if (!features.isEnabled("live")) notFound();
  const { unitId } = await params;
  return (
    <main style={{ maxWidth: 560, margin: "0 auto", padding: "40px 20px" }}>
      <p
        style={{
          fontFamily: "var(--mono)",
          fontSize: 11,
          letterSpacing: "0.14em",
          textTransform: "uppercase",
          color: "var(--accent)",
          margin: "0 0 8px",
        }}
      >
        live / {unitId}
      </p>
      <h1 style={{ fontSize: 22, margin: "0 0 16px" }}>ライブ投票</h1>
      <LiveRoom unitId={unitId} />
    </main>
  );
}

apps/<app>/src/app/live/[unitId]/LiveRoom.tsx(client。@event/feedbacktype-only import = client bundle に server 専用 barrel を持ち込まない):

"use client";

import { useCallback, useEffect, useState, type CSSProperties } from "react";
// type-only import はコンパイル時に消える = client bundle に feedback barrel を持ち込まない。
import type { LivePrompt, LiveResults } from "@event/feedback";

/**
 * 参加者画面 — GET /api/live/[unitId] を 2 秒ポーリングし、active お題に投票して
 * 結果バーをライブ描画する。リアルタイムは rev(promptRev / results.rev)の観測で、
 * WebSocket は使わない。1 人 1 票の dedup はサーバ側(voterHash)。
 */

type LiveSnapshot = {
  ok: boolean;
  view: string;
  viewRev: number;
  prompt: LivePrompt | null;
  promptRev: number;
  results: LiveResults | null;
};

const POLL_MS = 2000;

export function LiveRoom({ unitId }: { unitId: string }) {
  const [snap, setSnap] = useState<LiveSnapshot | null>(null);
  // promptId → 投票した optionId(同じお題への再投票 UI を抑止。真の dedup はサーバ)。
  const [votedFor, setVotedFor] = useState<Record<string, string>>({});
  const [busy, setBusy] = useState(false);

  const refresh = useCallback(async () => {
    try {
      const res = await fetch(`/api/live/${encodeURIComponent(unitId)}`, { cache: "no-store" });
      if (res.ok) setSnap((await res.json()) as LiveSnapshot);
    } catch {
      // 瞬断はスキップ(次のポーリングで回復する)
    }
  }, [unitId]);

  useEffect(() => {
    void refresh();
    const t = setInterval(() => void refresh(), POLL_MS);
    return () => clearInterval(t);
  }, [refresh]);

  async function vote(promptId: string, optionId: string) {
    if (busy) return;
    setBusy(true);
    try {
      const res = await fetch(`/api/live/${encodeURIComponent(unitId)}`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ promptId, optionId }),
      });
      const data = (await res.json()) as { ok?: boolean };
      if (data.ok) setVotedFor((v) => ({ ...v, [promptId]: optionId }));
      await refresh(); // 自票を即反映(次のポーリングを待たない)
    } catch {
      // 送信失敗は無視(ボタンは押し直せる)
    } finally {
      setBusy(false);
    }
  }

  if (!snap) return <p style={{ fontSize: 13, opacity: 0.6 }}>接続中…</p>;

  const prompt = snap.prompt;
  if (!prompt) {
    return (
      <p style={{ fontSize: 14, opacity: 0.65, padding: "24px 0" }}>
        お題を待っています… 主催が開始すると自動でここに表示されます。
      </p>
    );
  }

  const myVote = votedFor[prompt.id];
  const results = snap.results;
  const total = results?.total ?? 0;

  return (
    <div>
      <p style={{ fontSize: 17, fontWeight: 600, margin: "0 0 14px" }}>{prompt.question}</p>

      <div style={{ display: "grid", gap: 8 }}>
        {(prompt.options ?? []).map((o) => {
          const count = results?.counts[o.id] ?? 0;
          const pct = total > 0 ? Math.round((count / total) * 100) : 0;
          const selected = myVote === o.id;
          return (
            <button
              key={o.id}
              type="button"
              disabled={busy || myVote !== undefined}
              onClick={() => void vote(prompt.id, o.id)}
              style={{
                position: "relative",
                overflow: "hidden",
                textAlign: "left",
                padding: "12px 14px",
                fontSize: 14,
                fontFamily: "inherit",
                borderRadius: 8,
                border: selected ? "2px solid var(--accent)" : "1px solid rgba(20,20,15,.2)",
                background: "transparent",
                cursor: myVote === undefined ? "pointer" : "default",
              }}
            >
              {/* 結果バー(票が入ると幅がライブに伸びる) */}
              <span
                aria-hidden
                style={{
                  position: "absolute",
                  inset: 0,
                  width: `${pct}%`,
                  background: "var(--accent-soft)",
                  transition: "width .4s ease",
                }}
              />
              <span style={{ position: "relative", display: "flex", justifyContent: "space-between", gap: 12 }}>
                <span>
                  {o.label}
                  {selected ? " ✓" : ""}
                </span>
                <span style={{ opacity: 0.6, fontVariantNumeric: "tabular-nums" }}>
                  {count}票{total > 0 ? ` · ${pct}%` : ""}
                </span>
              </span>
            </button>
          );
        })}
      </div>

      <p style={{ fontSize: 12, opacity: 0.55, marginTop: 12 }}>
        {myVote !== undefined ? "投票済み — 結果はライブ更新されます。" : "タップで投票(1 人 1 票)。"}
        {" "}合計 {total} 票
      </p>
    </div>
  );
}

7. admin 主催コンソール

apps/<app>/src/app/admin/live/page.tsx(server・adminAuth + feature-gate):

import { notFound, redirect } from "next/navigation";
import { cookies } from "next/headers";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { LiveHost } from "./LiveHost";

export const dynamic = "force-dynamic";

/**
 * /admin/live — 主催コンソール。unit(セッション)ID を入れると host key が出て、
 * お題の開始/停止・view 切替・ライブ集計ができる。host key は登壇者/モデレーターに
 * 配布でき、受け取った人は admin ログイン無しで host API を直接叩ける。
 */
export default async function LiveAdminPage() {
  if (!features.isEnabled("live")) notFound();
  const cookie = (await cookies()).get(adminAuth.cookieName)?.value;
  if (!adminAuth.isAuthedFromValue(cookie)) redirect("/login");

  // host key の導出秘密(LIVE_HOST_SECRET ?? ADMIN_PASSWORD)が無いと機能は安全に停止する。
  const secretConfigured = !!(process.env.LIVE_HOST_SECRET ?? process.env.ADMIN_PASSWORD);

  return (
    <main style={{ maxWidth: 720, margin: "0 auto", padding: "48px 24px" }}>
      <p
        style={{
          fontFamily: "var(--mono)",
          fontSize: 11,
          letterSpacing: "0.14em",
          textTransform: "uppercase",
          color: "var(--accent)",
          margin: "0 0 8px",
        }}
      >
        live / host
      </p>
      <h1 style={{ fontSize: 26, margin: "0 0 4px" }}>ライブ投票</h1>
      <p style={{ fontSize: 13, opacity: 0.65, margin: "0 0 16px", maxWidth: "72ch" }}>
        unit ID(セッション ID など任意の識別子)ごとに独立した投票ルームが立つ。参加者には{" "}
        <code>/live/&lt;unit ID&gt;</code> を QR / リンクで配る。
      </p>
      {!secretConfigured && (
        <p
          style={{
            fontSize: 12.5,
            margin: "0 0 24px",
            padding: "10px 14px",
            borderRadius: 4,
            border: "1px solid rgba(20,20,15,.14)",
            background: "rgba(20,20,15,.05)",
          }}
        >
          <b>LIVE_HOST_SECRET / ADMIN_PASSWORD が未設定</b> — host key を導出できないため、
          お題の開始/停止は動きません。どちらかの env を設定してください。
        </p>
      )}
      <LiveHost />
    </main>
  );
}

apps/<app>/src/app/admin/live/LiveHost.tsx(client):

"use client";

import { useCallback, useEffect, useState, type CSSProperties } from "react";
// type-only import はコンパイル時に消える = client bundle に feedback barrel を持ち込まない。
import type { LivePrompt, LiveResults } from "@event/feedback";

/**
 * 主催コンソール(client)。
 *  1. unit ID を入れて「接続」→ GET /api/live/[unitId]/host(adminAuth)で host key を取得・表示
 *  2. お題(question + 選択肢 2〜4)を書いて「開始」→ POST(host key 照合)
 *  3. 集計は GET /api/live/[unitId] を 2 秒ポーリングでライブ表示。「終了」で active を消す
 *
 * POST は admin cookie では通らず host key を要求する — 登壇者に key を渡せば
 * この画面が無くても同じ API を叩ける(admin UI も同じ経路を通して検証を兼ねる)。
 */

type LiveSnapshot = {
  ok: boolean;
  view: string;
  viewRev: number;
  prompt: LivePrompt | null;
  promptRev: number;
  results: LiveResults | null;
};

const POLL_MS = 2000;
const VIEWS = ["survey", "qa"] as const;

const inputStyle: CSSProperties = {
  width: "100%",
  padding: "8px 10px",
  fontSize: 13,
  border: "1px solid rgba(20,20,15,.2)",
  borderRadius: 4,
  boxSizing: "border-box",
  fontFamily: "inherit",
};

const buttonStyle: CSSProperties = {
  padding: "8px 16px",
  fontSize: 13,
  fontFamily: "inherit",
  borderRadius: 6,
  border: "1px solid rgba(20,20,15,.25)",
  background: "transparent",
  cursor: "pointer",
};

export function LiveHost() {
  const [unitInput, setUnitInput] = useState("");
  const [unitId, setUnitId] = useState<string | null>(null); // 接続済み unit
  const [hostKey, setHostKey] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  const [question, setQuestion] = useState("");
  const [labels, setLabels] = useState<string[]>(["", "", "", ""]);
  const [busy, setBusy] = useState(false);
  const [snap, setSnap] = useState<LiveSnapshot | null>(null);

  // --- 1. 接続: host key を取得(adminAuth 必須の GET) ---
  async function connect() {
    const uid = unitInput.trim();
    if (!uid) return;
    setError(null);
    try {
      const res = await fetch(`/api/live/${encodeURIComponent(uid)}/host`, { cache: "no-store" });
      const data = (await res.json()) as { ok?: boolean; hostKey?: string; error?: string };
      if (!data.ok || !data.hostKey) {
        setError(
          data.error === "host_key_unavailable"
            ? "host key を導出できません(LIVE_HOST_SECRET / ADMIN_PASSWORD 未設定)"
            : `接続に失敗しました(${data.error ?? res.status})`,
        );
        return;
      }
      setUnitId(uid);
      setHostKey(data.hostKey);
      setSnap(null);
    } catch {
      setError("接続に失敗しました(ネットワーク)");
    }
  }

  // --- 3. 集計ポーリング(公開 GET と同じもの) ---
  const refresh = useCallback(async () => {
    if (!unitId) return;
    try {
      const res = await fetch(`/api/live/${encodeURIComponent(unitId)}`, { cache: "no-store" });
      if (res.ok) setSnap((await res.json()) as LiveSnapshot);
    } catch {
      // 瞬断はスキップ
    }
  }, [unitId]);

  useEffect(() => {
    if (!unitId) return;
    void refresh();
    const t = setInterval(() => void refresh(), POLL_MS);
    return () => clearInterval(t);
  }, [unitId, refresh]);

  // --- 2. host アクション(開始 / 停止 / view 切替) ---
  async function post(body: Record<string, unknown>) {
    if (!unitId || !hostKey || busy) return;
    setBusy(true);
    setError(null);
    try {
      const res = await fetch(`/api/live/${encodeURIComponent(unitId)}/host`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ key: hostKey, ...body }),
      });
      const data = (await res.json()) as { ok?: boolean; error?: string };
      if (!data.ok) setError(`失敗: ${data.error ?? res.status}`);
      await refresh();
    } catch {
      setError("送信に失敗しました(ネットワーク)");
    } finally {
      setBusy(false);
    }
  }

  const filledLabels = labels.map((l) => l.trim()).filter(Boolean);
  const canStart = question.trim().length > 0 && filledLabels.length >= 2 && !busy;

  function start() {
    // 空欄を除いた 2〜4 択。id はサーバ側フォールバック(o1..o4)に任せる。
    void post({ question: question.trim(), options: filledLabels.map((label) => ({ label })) });
  }

  if (!unitId) {
    return (
      <section>
        <label style={{ display: "block", fontSize: 12, opacity: 0.7, marginBottom: 6 }}>
          unit ID(例: セッション ID。参加者 URL は /live/&lt;unit ID&gt;)
        </label>
        <div style={{ display: "flex", gap: 8 }}>
          <input
            value={unitInput}
            onChange={(e) => setUnitInput(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && void connect()}
            placeholder="demo-session"
            style={{ ...inputStyle, flex: 1 }}
          />
          <button type="button" onClick={() => void connect()} disabled={!unitInput.trim()} style={buttonStyle}>
            接続
          </button>
        </div>
        {error && <p style={{ fontSize: 12.5, color: "#b3261e", marginTop: 10 }}>{error}</p>}
      </section>
    );
  }

  const prompt = snap?.prompt ?? null;
  const results = snap?.results ?? null;
  const total = results?.total ?? 0;

  return (
    <section style={{ display: "grid", gap: 24 }}>
      {/* 接続情報 + host key */}
      <div
        style={{
          padding: "12px 14px",
          borderRadius: 6,
          border: "1px solid rgba(20,20,15,.14)",
          fontSize: 13,
          display: "grid",
          gap: 4,
        }}
      >
        <div>
          unit: <b>{unitId}</b>
          <button
            type="button"
            onClick={() => {
              setUnitId(null);
              setHostKey(null);
              setSnap(null);
            }}
            style={{ ...buttonStyle, padding: "2px 10px", fontSize: 12, marginLeft: 12 }}
          >
            変更
          </button>
        </div>
        <div>
          host key:{" "}
          <code style={{ fontSize: 15, letterSpacing: "0.08em", color: "var(--accent)" }}>{hostKey}</code>
          <span style={{ opacity: 0.55, fontSize: 12, marginLeft: 8 }}>
            (登壇者/モデレーターに配布可。admin ログイン不要で host API を叩ける)
          </span>
        </div>
        <div style={{ opacity: 0.7 }}>
          参加者 URL: <code>/live/{unitId}</code>
        </div>
      </div>

      {/* お題フォーム */}
      <div>
        <h2 style={{ fontSize: 15, margin: "0 0 10px" }}>お題(poll)</h2>
        <input
          value={question}
          onChange={(e) => setQuestion(e.target.value)}
          placeholder="質問(140 字まで)"
          maxLength={140}
          style={{ ...inputStyle, marginBottom: 8 }}
        />
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 10 }}>
          {labels.map((l, i) => (
            <input
              key={i}
              value={l}
              onChange={(e) => setLabels((prev) => prev.map((x, j) => (j === i ? e.target.value : x)))}
              placeholder={`選択肢 ${String.fromCharCode(65 + i)}${i < 2 ? "(必須)" : "(任意)"}`}
              maxLength={60}
              style={inputStyle}
            />
          ))}
        </div>
        <div style={{ display: "flex", gap: 8 }}>
          <button
            type="button"
            onClick={start}
            disabled={!canStart}
            style={{
              ...buttonStyle,
              border: "1px solid var(--accent)",
              color: "var(--accent)",
              opacity: canStart ? 1 : 0.45,
            }}
          >
            開始(既存のお題を置き換え)
          </button>
          <button type="button" onClick={() => void post({ action: "stop" })} disabled={busy || !prompt} style={buttonStyle}>
            終了
          </button>
          <span style={{ flex: 1 }} />
          {/* view-follow: 参加者全員の画面を切替える(viewRev が上がり全端末が追従) */}
          {VIEWS.map((v) => (
            <button
              key={v}
              type="button"
              onClick={() => void post({ action: "view", view: v })}
              disabled={busy}
              style={{
                ...buttonStyle,
                fontSize: 12,
                ...(snap?.view === v ? { borderColor: "var(--accent)", color: "var(--accent)" } : {}),
              }}
            >
              view: {v}
            </button>
          ))}
        </div>
        {error && <p style={{ fontSize: 12.5, color: "#b3261e", marginTop: 10 }}>{error}</p>}
      </div>

      {/* ライブ結果 */}
      <div>
        <h2 style={{ fontSize: 15, margin: "0 0 10px" }}>
          ライブ結果{" "}
          <span style={{ fontWeight: 400, fontSize: 12, opacity: 0.6 }}>
            {prompt ? `「${prompt.question}」 · 合計 ${total} 票(2 秒更新)` : "お題は停止中"}
          </span>
        </h2>
        {prompt ? (
          <div style={{ display: "grid", gap: 6 }}>
            {(prompt.options ?? []).map((o) => {
              const count = results?.counts[o.id] ?? 0;
              const pct = total > 0 ? Math.round((count / total) * 100) : 0;
              return (
                <div key={o.id} style={{ fontSize: 13 }}>
                  <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 2 }}>
                    <span>{o.label}</span>
                    <span style={{ opacity: 0.65, fontVariantNumeric: "tabular-nums" }}>
                      {count}票 · {pct}%
                    </span>
                  </div>
                  <div style={{ height: 8, borderRadius: 4, background: "rgba(20,20,15,.08)", overflow: "hidden" }}>
                    <div
                      style={{
                        height: "100%",
                        width: `${pct}%`,
                        background: "var(--accent)",
                        transition: "width .4s ease",
                      }}
                    />
                  </div>
                </div>
              );
            })}
          </div>
        ) : (
          <p style={{ fontSize: 13, opacity: 0.55 }}>お題を開始すると集計がここに出ます。</p>
        )}
      </div>
    </section>
  );
}

8. 型チェックと検証

monorepo root で:

yarn install
yarn workspace <app> typecheck   # 対象 app にスコープ
yarn workspace <app> build       # /live/[unitId] と /api/live/* が route 一覧に出ること

dev での end-to-end 検証(KV 未設定でも in-memory shim で動く。dev は adminAuth bypass で ログイン不要。host key の導出には LIVE_HOST_SECRETADMIN_PASSWORD のどちらかが必要 — 無ければ .env.localLIVE_HOST_SECRET=dev-secret を 1 行足す):

  1. yarn workspace <app> dev
  2. /admin/live を開き、unit ID に demo と入れて「接続」→ 8 文字の host key が表示される
  3. 質問と選択肢 2 つを入れて「開始」
  4. 別タブ/live/demo を開く → お題が表示される → 選択肢をタップして投票
  5. admin タブの「ライブ結果」に票が 2 秒以内に反映される(参加者タブの結果バーも伸びる)
  6. 同じタブでもう一度投票できないこと(1 人 1 票 = ボタンが投票済みで固定)を確認
  7. 「終了」→ 参加者タブが「お題を待っています…」に戻る

完了条件: build が通り、host の開始 → 別タブ投票 → 2 秒以内の集計反映 → 終了の一巡が動く。

本番は Upstash(KV_REST_API_URL / KV_REST_API_TOKEN)を設定する — dev shim はプロセス内 メモリなので serverless では使えない。host key の一括ローテーションは LIVE_HOST_SECRET の 差し替えだけ(保存しない導出式なので個別失効は無い。章28 参照)。