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

サーベイ & リーダーボードを追加する

既存イベント app に匿名アンケート + IMDb 加重リーダーボードを配線し、公開 submit API と admin 集計ページを動かす

前提: new-event前提: add-session-cards解説 ch.26解説 ch.27module: サーベイ & リーダーボード

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

1. 依存を追加

apps/<app>/package.json の dependencies に追加:

"@event/feedback": "*"

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

2. 機能を有効化

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

surveys: true,

FEATURES に surveys descriptor が無ければ、cms を参考に nav /admin/surveys と env BLOB_READ_WRITE_TOKEN を持つ descriptor を追加する。starter には既に入っている。)

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

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

import { createFeedbackStore } from "@event/feedback";
import { store as contentStore } from "@/lib/cms"; // dev fs fallback に使う

/** dev は content/ に fs 保存、本番は BLOB_READ_WRITE_TOKEN で Blob。 */
export const feedbackStore = createFeedbackStore({
  contentStore,
  pathPrefix: "feedback/responses/",
});

4. 公開 submit ルート

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

import { createSubmitHandler } from "@event/feedback";
import { createFoursConfig, listSessionsFromEvent } from "@event/fours-sdk";
import { feedbackStore } from "@/lib/feedback";

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

const handler = createSubmitHandler({
  store: feedbackStore,
  // unitId(=session id) を検証し、評価可能な subject(=speaker) を返す
  resolveUnit: async (unitId) => {
    const sessions = (await listSessionsFromEvent(createFoursConfig())) ?? [];
    const s = sessions.find((x) => x.id === unitId && x.visibility === "public");
    if (!s) return null; // 404
    return { ok: true, allowedSubjectIds: s.speakerIds };
  },
});

export async function POST(req: Request, ctx: { params: Promise<{ unitId: string }> }) {
  const { unitId } = await ctx.params;
  return handler(req, unitId);
}

5. admin 集計ページ

apps/<app>/src/app/admin/surveys/page.tsx:

import { redirect, notFound } from "next/navigation";
import { cookies } from "next/headers";
import { createFoursConfig, listSessionsFromEvent, listSpeakersFromEvent } from "@event/fours-sdk";
import { rankSubjects } from "@event/feedback";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { feedbackStore } from "@/lib/feedback";

export const dynamic = "force-dynamic";

export default async function SurveysPage() {
  if (!features.isEnabled("surveys")) notFound();
  const cookie = (await cookies()).get(adminAuth.cookieName)?.value;
  if (!adminAuth.isAuthedFromValue(cookie)) redirect("/login");

  const config = createFoursConfig();
  const [sessions, speakers] = await Promise.all([
    listSessionsFromEvent(config),
    listSpeakersFromEvent(config),
  ]);
  const publicSessions = (sessions ?? []).filter((s) => s.visibility === "public");
  const responses = await feedbackStore.listAllResponses(publicSessions.map((s) => s.id));
  const ranking = rankSubjects(responses);
  const nameById = new Map((speakers ?? []).map((s) => [s.id, s.name]));

  return (
    <main style={{ maxWidth: 720, margin: "0 auto", padding: "48px 24px" }}>
      <h1 style={{ fontSize: 24 }}>サーベイ結果</h1>
      <p style={{ fontSize: 13, opacity: 0.6 }}>
        回答 {ranking.totalResponses} 件 / 会場基準 {Math.round(ranking.baselineQuality)}
      </p>
      <ol style={{ paddingLeft: 20 }}>
        {ranking.subjects.slice(0, 30).map((s) => (
          <li key={s.subjectId} style={{ marginBottom: 6 }}>
            <b>{s.score}</b> — {nameById.get(s.subjectId) ?? s.subjectId}
            <span style={{ opacity: 0.5, fontSize: 12, marginLeft: 8 }}>{s.votes}票</span>
          </li>
        ))}
      </ol>

rankSubjects() の戻り値フィールドは subjectsSubjectRankingsubjectId / score / votes を持つ。) </main> ); }


## 6. インストールと型チェック

monorepo root で:

yarn install yarn workspace <app> typecheck # 対象 app にスコープ(他パッケージの状態に依存しない)


## 7. 検証

yarn workspace <app> build # ビルドが通ること


dev で確認する場合(`BLOB_READ_WRITE_TOKEN` 未設定でも contentStore fs fallback で動く):

1. `yarn workspace <app> dev`
2. `POST /api/survey/<公開session id>/submit` に `{ "subjectRatings": { "<speakerId>": "great" } }` → `{ ok: true }`
3. `/admin/surveys` にスコアが出る(dev bypass でログイン不要)

完了条件: build が通り、submit が `ok:true` を返し、admin にランキングが描画される。

## 8. 公開フォーム(来場者の入口 — swipe UX)

admin 集計だけでは回答が集まらない。来場者が評価を入れる公開ページを足す。
これで submit API に実データが流れ、7 のランキングが埋まる。

UI 本体は `@event/feedback/ui` の **SwipeSurvey**(1 subject = 1 カード、左右スワイプ
±80px or ボタン 3 つで評価 → 次カードへ → 全員評価後にコメント + 送信。Pointer Events で
タッチ/マウス両対応・依存ゼロ・reduced-motion 対応)。
**client からは必ず `/ui` サブパスを import する**(barrel `@event/feedback` は node:crypto を
含む store/live/hostKey を再輸出するため client bundle に持ち込めない)。

`apps/<app>/src/app/survey/[unitId]/page.tsx`(server・認証不要・feature-gate。
subject には Speaker の `photoUri` を `photoUrl` として渡すとカードに顔写真が出る):

import { notFound } from "next/navigation"; import { createFoursConfig, listSessionsFromEvent, listSpeakersFromEvent } from "@event/fours-sdk"; import { features } from "@/event.config"; import { SurveyForm } from "./SurveyForm";

export const dynamic = "force-dynamic";

export default async function SurveyPage({ params }: { params: Promise<{ unitId: string }> }) { if (!features.isEnabled("surveys")) notFound(); const { unitId } = await params; const config = createFoursConfig(); const [sessions, speakers] = await Promise.all([ listSessionsFromEvent(config), listSpeakersFromEvent(config), ]); const session = (sessions ?? []).find((s) => s.id === unitId && s.visibility === "public"); if (!session) notFound(); const speakerById = new Map((speakers ?? []).map((s) => [s.id, s])); const subjects = session.speakerIds.map((id) => { const sp = speakerById.get(id); return { id, name: sp?.name ?? id, photoUrl: sp?.photoUri }; }); return ( <main style={{ maxWidth: 560, margin: "0 auto", padding: "40px 20px" }}> <h1 style={{ fontSize: 22 }}>{session.title}</h1> <SurveyForm unitId={unitId} subjects={subjects} /> </main> ); }


`apps/<app>/src/app/survey/[unitId]/SurveyForm.tsx`(client。swipe UI は SwipeSurvey に
委譲し、この wrapper は fetch 送信 + honeypot `website` + thanks 画面だけを持つ):

"use client"; import { useState } from "react"; import { SwipeSurvey } from "@event/feedback/ui";

type Subject = { id: string; name: string; photoUrl?: string };

export function SurveyForm({ unitId, subjects }: { unitId: string; subjects: Subject[] }) { const [website, setWebsite] = useState(""); // honeypot(bot が埋める) const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle"); const [avg, setAvg] = useState<number | null>(null); const [err, setErr] = useState("");

async function handleSubmit(ratings: Record<string, string>, comment: string) { setState("sending"); setErr(""); try { const res = await fetch(/api/survey/${encodeURIComponent(unitId)}/submit, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ subjectRatings: ratings, comment: comment || undefined, website, // honeypot locale: "ja", }), }); const data = (await res.json()) as { ok?: boolean; avg?: number | null; error?: string }; if (!res.ok || !data.ok) { setErr(data.error ?? HTTP ${res.status}); setState("error"); return; } setAvg(typeof data.avg === "number" ? data.avg : null); setState("done"); } catch { setErr("送信に失敗しました。通信環境を確認してもう一度お試しください。"); setState("error"); } }

if (state === "done") { return ( <div style={{ textAlign: "center", padding: "32px 0" }}> <div style={{ fontSize: 40 }}>🙏</div> <h2 style={{ fontSize: 18, margin: "8px 0" }}>ありがとうございました</h2> {avg != null && <p style={{ fontSize: 13, opacity: 0.6 }}>あなたの平均スコア {avg.toFixed(1)} / 3</p>} <button type="button" onClick={() => setState("idle")} style={{ marginTop: 16, background: "none", border: "none", color: "#3355dd", cursor: "pointer", fontSize: 13, textDecoration: "underline" }}> もう一度回答する(上書き) </button> </div> ); }

return ( <div> <SwipeSurvey subjects={subjects} onSubmit={handleSubmit} submitting={state === "sending"} /> {/ honeypot: 画面外に隠す。人間は触れない /} <input type="text" tabIndex={-1} autoComplete="off" aria-hidden value={website} onChange={(e) => setWebsite(e.target.value)} style={{ position: "absolute", left: "-9999px", width: 1, height: 1 }} /> {state === "error" && <p style={{ color: "#c00", fontSize: 13, marginTop: 10 }}>{err}</p>} <p style={{ fontSize: 11, opacity: 0.45, marginTop: 8, textAlign: "center" }}> 匿名で保存されます。同じ端末からは上書きされます。 </p> </div> ); }


評価スケールを変えるときは `SwipeSurvey` に `scale`(`RatingScale`。既定 `DEFAULT_FACE_SCALE` =
ivs 3 顔)を渡し、submit route の `createSubmitHandler({ scale })` と同じものを注入する。
テーマは `brandVars`(`--survey-accent` / `--survey-card-bg` / `--survey-line` / `--survey-radius`)で上書きできる。

来場者に `/survey/<公開 session id>` を QR/リンクで配れば、匿名評価が集まり /admin/surveys に反映される。