このレシピは @event/feedback(実装済み・検証済み)をイベント app に配線する完全手順。 対象 app を apps/<app> とする(例では apps/starter)。上から順に実行すれば完了する。
1. 依存を追加
apps/<app>/package.json の dependencies に追加:
"@event/feedback": "*"
apps/<app>/next.config.ts の transpilePackages 配列に "@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() の戻り値フィールドは subjects。SubjectRanking は subjectId / 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 に反映される。