このレシピは @event/booking(実装済み・検証済み)をイベント app に配線する完全手順。 対象 app を apps/<app> とする(例では apps/starter)。
設計の要点: @event/booking は 2 系統。
- 見積 (quote.ts) —
computeQuoteは純関数(税 inclusive/exclusive・basis-point 税率)。DB 不要でそのまま動く。保存はcreateBlobQuoteStore(dev=content/quotes.json / 本番=Blob)。 - 予約 (reservation.ts) — 定員 / waitlist / 冪等 dedup。
createReservationStoreは Neon(drizzle Postgres)必須。
まず見積だけで end-to-end を通す(dev で確実に動く)。予約は Neon が要るので add-database を先に済ませてから最後に配線する。
重要 — client/server 境界: @event/booking の barrel は @vercel/blob / drizzle-orm / node:crypto を top-level import する server-only モジュール。client component から import してはいけない。公開フォームは server component が catalog を読み、入力 UI(client)は /api/booking/quote 経由で computeQuote を叩く。
1. 依存と機能
apps/<app>/package.json の dependencies に "@event/booking": "*"、next.config.ts の transpilePackages に "@event/booking" を追加(starter は両方済み)。
apps/<app>/src/event.config.ts の features で:
booking: true,
FEATURES に booking descriptor が無ければ追加する(nav に公開 /booking と /admin/booking、env に BOOKING_OPEN、予約を使うなら DATABASE_URL を required: false で):
{
key: "booking",
label: "予約 & 見積",
description: "見積(computeQuote 純関数)+ 予約/キャンセル待ち(@event/booking)",
nav: [
{ id: "booking-public", group: "予約", label: "見積フォーム(公開)", href: "/booking", code: "BKG", order: 1, external: true },
{ id: "booking-admin", group: "予約", label: "予約 & 見積", href: "/admin/booking", code: "BKA", order: 2 },
],
env: [
{ name: "BOOKING_OPEN", required: false, description: "受付 kill-switch(0 で停止・既定 open)" },
{ name: "DATABASE_URL", required: false, description: "予約/キャンセル待ちの保存(見積のみなら不要)" },
{ name: "BLOB_READ_WRITE_TOKEN", required: false, description: "見積レコードの Blob 保存(dev は content/quotes.json)" },
],
manualChapters: [29, 30],
},
2. booking の配線ファイル(見積 設定 + store)
apps/<app>/src/lib/booking.ts(server 専用。client から import しない):
import {
computeQuote, createBlobQuoteStore, defineBookingConfig, makeEstimateNo, yen,
type BookingConfig, type QuoteLineItem, type QuoteRecord, type QuoteStore,
type QuoteTotals, type TaxMode,
} from "@event/booking";
import { store as contentStore } from "@/lib/cms";
export type BookingProduct = { sku: string; label: string; unitPrice: number };
/** 外税(単価は税抜・消費税を別建て)。内税なら "inclusive"。 */
export const BOOKING_TAX_MODE: TaxMode = "exclusive";
/** basis-point 整数(1000 = 10%)。 */
export const BOOKING_TAX_RATE_BP = 1000;
export const BOOKING_ESTIMATE_PREFIX = "Q";
/** 品目カタログ(イベントごとにここを書き換える。UI もこれ駆動=ハードコードしない)。 */
export const BOOKING_PRODUCTS: BookingProduct[] = [
{ sku: "booth-standard", label: "スタンダードブース(3m×3m)", unitPrice: 150000 },
{ sku: "booth-corner", label: "コーナーブース(2面開放)", unitPrice: 220000 },
{ sku: "table-1day", label: "商談テーブル(1 日)", unitPrice: 30000 },
{ sku: "power-1500w", label: "電源オプション(1500W)", unitPrice: 12000 },
];
const int = (v: unknown) => { const x = Math.floor(Number(v)); return Number.isFinite(x) ? x : 0; };
/** 数量マップ(sku→数量)を catalog 価格で lineItems に(サーバが価格の真実源)。 */
export function toLineItems(quantities: Record<string, unknown> | undefined): QuoteLineItem[] {
const q = quantities ?? {};
return BOOKING_PRODUCTS
.map((p) => ({ label: p.label, qty: Math.max(0, int(q[p.sku])), unitPrice: p.unitPrice }))
.filter((li) => li.qty > 0);
}
/** client が直接 lineItems を渡してきた場合の sanitize。 */
export function sanitizeLineItems(raw: unknown): QuoteLineItem[] {
if (!Array.isArray(raw)) return [];
return raw.map((r) => {
const o = (r ?? {}) as Record<string, unknown>;
return { label: typeof o.label === "string" ? o.label : undefined, qty: Math.max(0, int(o.qty)), unitPrice: Math.max(0, int(o.unitPrice)) };
}).filter((li) => li.qty > 0 && li.unitPrice > 0);
}
export function quote(lineItems: QuoteLineItem[], discountAmount = 0): QuoteTotals {
return computeQuote(lineItems, BOOKING_TAX_MODE, BOOKING_TAX_RATE_BP, { discountAmount });
}
export type QuoteDisplay = { subtotalExcl: string; discount: string; taxable: string; tax: string; total: string };
export function displayTotals(t: QuoteTotals): QuoteDisplay {
return { subtotalExcl: yen(t.subtotalExcl), discount: yen(t.discount), taxable: yen(t.taxable), tax: yen(t.tax), total: yen(t.total) };
}
export function issueEstimateNo(now: Date = new Date()): string {
return makeEstimateNo(BOOKING_ESTIMATE_PREFIX, now);
}
/** 受付 kill-switch(BOOKING_OPEN=0 で停止・既定 open)。 */
export const bookingConfig: BookingConfig = defineBookingConfig(
process.env.BOOKING_OPEN !== "0",
"現在、事前予約・見積の受付を停止しています。",
);
/** dev=content/quotes.json / 本番=Blob。createBlobQuoteStore が両方を吸収する。 */
export const quoteStore: QuoteStore = createBlobQuoteStore({
contentStore, pathPrefix: "quotes/", fsResource: "quotes",
});
export type { QuoteRecord, QuoteTotals };
3. 見積計算 API(computeQuote 純関数・runtime nodejs)
apps/<app>/src/app/api/booking/quote/route.ts:
import {
BOOKING_TAX_MODE, BOOKING_TAX_RATE_BP, bookingConfig, displayTotals,
issueEstimateNo, quote, quoteStore, sanitizeLineItems, toLineItems, type QuoteRecord,
} from "@/lib/booking";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: Request): Promise<Response> {
const body = (await req.json().catch(() => ({}))) as {
quantities?: Record<string, unknown>; lineItems?: unknown; discountAmount?: unknown;
issue?: boolean; company?: string; contact?: string; email?: string; note?: string;
};
if (!bookingConfig.open) {
return Response.json({ ok: false, reason: "closed", message: bookingConfig.closedMessage }, { status: 200 });
}
const lineItems = Array.isArray(body.lineItems) ? sanitizeLineItems(body.lineItems) : toLineItems(body.quantities);
const discountAmount = Math.max(0, Math.floor(Number(body.discountAmount)) || 0);
const totals = quote(lineItems, discountAmount);
let estimateNo: string | null = null;
if (body.issue && lineItems.length > 0) {
estimateNo = issueEstimateNo();
const record: QuoteRecord = {
id: estimateNo, estimateNo, issuedAt: new Date().toISOString(),
company: (body.company ?? "").trim() || "(デモ発行)",
contact: body.contact?.trim() || undefined, email: body.email?.trim() || undefined,
note: body.note?.trim() || undefined, lineItems, totals, meta: { source: "booking-demo" },
};
try { await quoteStore.appendQuote(record); } catch { /* 保存 best-effort */ }
}
return Response.json({ ok: true, estimateNo, taxMode: BOOKING_TAX_MODE, taxRateBp: BOOKING_TAX_RATE_BP, lineItems, totals, display: displayTotals(totals) });
}
4. 公開見積フォーム(server page + client form)
apps/<app>/src/app/booking/page.tsx(server component — lib/booking から catalog を読む):
import { notFound } from "next/navigation";
import { features } from "@/event.config";
import { BOOKING_PRODUCTS, BOOKING_TAX_RATE_BP, bookingConfig } from "@/lib/booking";
import { BookingForm } from "./BookingForm";
export const dynamic = "force-dynamic";
export default function BookingPage() {
if (!features.isEnabled("booking")) notFound();
return (
<main style={{ maxWidth: 720, margin: "0 auto", padding: "56px 24px" }}>
<h1 style={{ fontSize: 30, margin: "8px 0 24px" }}>出展のお見積り</h1>
{bookingConfig.open ? (
<BookingForm products={BOOKING_PRODUCTS} taxRatePct={BOOKING_TAX_RATE_BP / 100} />
) : (
<div style={{ border: "1px solid var(--line)", padding: 20 }}>{bookingConfig.closedMessage}</div>
)}
</main>
);
}
apps/<app>/src/app/booking/BookingForm.tsx(client — @event/booking に依存しない。計算は API 経由):
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
type Product = { sku: string; label: string; unitPrice: number };
type QuoteDisplay = { subtotalExcl: string; discount: string; taxable: string; tax: string; total: string };
type QuoteResponse = { ok: true; estimateNo: string | null; display: QuoteDisplay } | { ok: false; reason: string; message?: string };
export function BookingForm({ products, taxRatePct }: { products: Product[]; taxRatePct: number }) {
const [qty, setQty] = useState<Record<string, number>>({});
const [discount, setDiscount] = useState(0);
const [company, setCompany] = useState("");
const [display, setDisplay] = useState<QuoteDisplay | null>(null);
const [estimateNo, setEstimateNo] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const seq = useRef(0);
const post = useCallback(async (issue: boolean): Promise<QuoteResponse> => {
const res = await fetch("/api/booking/quote", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ quantities: qty, discountAmount: discount, issue, company }),
});
return (await res.json()) as QuoteResponse;
}, [qty, discount, company]);
useEffect(() => {
const mine = ++seq.current;
setEstimateNo(null);
post(false).then((r) => { if (mine === seq.current && r.ok) setDisplay(r.display); }).catch(() => {});
}, [post]);
const anySelected = products.some((p) => (qty[p.sku] ?? 0) > 0);
async function onIssue() {
if (!anySelected || busy) return;
setBusy(true);
try { const r = await post(true); if (r.ok) { setDisplay(r.display); setEstimateNo(r.estimateNo); } }
finally { setBusy(false); }
}
return (
<div>
{products.map((p) => (
<div key={p.sku} style={{ display: "flex", justifyContent: "space-between", padding: "10px 0" }}>
<span>{p.label}(¥{p.unitPrice.toLocaleString("ja-JP")})</span>
<input type="number" min={0} value={qty[p.sku] ?? 0}
onChange={(e) => setQty((v) => ({ ...v, [p.sku]: Math.max(0, Math.floor(Number(e.target.value) || 0)) }))} />
</div>
))}
<div>値引き <input type="number" min={0} value={discount}
onChange={(e) => setDiscount(Math.max(0, Math.floor(Number(e.target.value) || 0)))} /></div>
<ul>
<li>小計(税抜): {display?.subtotalExcl ?? "—"}</li>
<li>消費税({taxRatePct}%): {display?.tax ?? "—"}</li>
<li><b>合計(税込): {display?.total ?? "—"}</b></li>
</ul>
<input type="text" placeholder="会社名(任意)" value={company} onChange={(e) => setCompany(e.target.value)} />
<button type="button" onClick={onIssue} disabled={!anySelected || busy}>
{busy ? "発行中…" : "見積番号を発行"}
</button>
{estimateNo ? <p>見積番号: <b>{estimateNo}</b></p> : null}
</div>
);
}
(starter の実ファイルは inline style で装飾済み。ここでは配線が分かる最小形。)
5. admin ページ(kill-switch + 見積一覧 + 予約は add-database 後)
apps/<app>/src/app/admin/booking/page.tsx:
import { redirect, notFound } from "next/navigation";
import { cookies } from "next/headers";
import { formatJst } from "@event/booking";
import { adminAuth } from "@/lib/adminAuth";
import { features } from "@/event.config";
import { bookingConfig, quoteStore } from "@/lib/booking";
export const dynamic = "force-dynamic";
export default async function AdminBookingPage() {
if (!features.isEnabled("booking")) notFound();
const cookie = (await cookies()).get(adminAuth.cookieName)?.value;
if (!adminAuth.isAuthedFromValue(cookie)) redirect("/login");
const quotes = await quoteStore.listQuotes().catch(() => []);
return (
<main style={{ maxWidth: 760, margin: "0 auto", padding: "48px 24px" }}>
<h1>予約 & 見積</h1>
<p>受付: <b>{bookingConfig.open ? "open" : "closed"}</b>(BOOKING_OPEN=0 で停止)</p>
<p>定員/waitlist(SlotAvailability: reserved/waitlist/capacity/remaining/full)は Neon が要る。
add-database 後に createReservationStore を配線するとここに予約一覧が出る。</p>
<h2>発行済み見積({quotes.length} 件)</h2>
<ul>
{quotes.map((q) => (
<li key={q.id}>{q.estimateNo} — {formatJst(q.issuedAt)} — {q.company} — ¥{q.totals.total.toLocaleString("ja-JP")}</li>
))}
</ul>
</main>
);
}
6. インストール・型チェック・検証
yarn install
yarn workspace <app> typecheck
yarn workspace <app> dev
見積(DB 不要・dev で必ず動く):
/bookingを開く → 数量を変えると小計/消費税/合計が即時試算される(/api/booking/quoteが computeQuote で計算)curl -X POST /api/booking/quote -H 'content-type: application/json' -d '{"quantities":{"booth-standard":1},"discountAmount":0}'→{ ok:true, totals:{ subtotalExcl:150000, tax:15000, total:165000, ... } }- 「見積番号を発行」→
Q<YYYYMMDD>-XXXXが採番され、dev ではcontent/quotes.jsonに保存される /admin/booking(dev bypass でログイン不要)に発行済み見積が並ぶ
7. 予約 & キャンセル待ちを有効化(add-database 後)
予約は Neon が要る。add-database を済ませてから足す。dev で DATABASE_URL 未設定でも落とさない (route が fail-safe を返す)ように配線する。
7-1. lib/booking.ts に予約 store を足す
import {
createReservationStore, createBookingActions, defineReservationTable,
type BookingActions, type BookingDb, type ReservationStore, type ReservationTable,
} from "@event/booking";
import { db } from "@/lib/db"; // add-database の lazy Neon Proxy(未接続でも import は落ちない)
export const BOOKING_SLOTS = [
{ slotId: "day1-am", resourceKey: "booth-tour", label: "1日目 午前 — ブース見学" },
// …イベントに合わせて増やす
];
export const SLOT_CAPACITY = 40;
export function slotById(id: string) { return BOOKING_SLOTS.find((s) => s.slotId === id); }
export const reservationTable: ReservationTable = defineReservationTable("event_reservation");
export const reservationStore: ReservationStore = createReservationStore({
// ★ crm-schema の db は typed schema 付き(NeonHttpDatabase<schema>)。BookingDb は schema 非依存の
// PgDatabase を要求するので cast で橋渡しする(クエリはテーブル駆動なので runtime は安全)。
// cast を省くと typecheck が db 型不一致で落ちる。
db: db as unknown as BookingDb,
table: reservationTable,
capacity: SLOT_CAPACITY,
});
export const bookingActions: BookingActions = createBookingActions({
store: reservationStore,
config: () => bookingConfig, // 見積と kill-switch を共有(2 で定義済み)
rateLimit: { windowSec: 60, max: 5 },
});
/** 予約が使える状態か(Neon 必須)。未設定なら実クエリを投げず fail-safe 表示にする。 */
export const isReservationConfigured = () => Boolean(process.env.DATABASE_URL);
7-2. テーブル migration
apps/<app>/migrations/0002_event_reservation.sql に予約テーブルを置く。SQL は package が生成する:
import { reservationTableSql } from "@event/booking";
console.log(reservationTableSql("event_reservation")); // CREATE TABLE ... + partial-unique index(slot_id, lower(email))
add-database の scripts/apply-migrations.mjs の dir に apps/<app>/migrations も足して流す (固定コア → app 固有テーブルの順)。
7-3. 公開予約 route
apps/<app>/src/app/api/booking/reserve/route.ts:
import { bookingActions, isReservationConfigured, slotById } from "@/lib/booking";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: Request): Promise<Response> {
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
if (!isReservationConfigured())
return Response.json({ ok: false, reason: "db_unconfigured", message: "オンライン予約は準備中です。" });
const slot = slotById(typeof body.slotId === "string" ? body.slotId : "");
if (!slot) return Response.json({ ok: false, reason: "invalid", errors: { slotId: "枠を選択してください。" } });
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
// resourceKey はカタログ由来 = サーバが真実源。honeypot(website)/ip は submit が処理。
try {
return Response.json(await bookingActions.submit({ ...body, slotId: slot.slotId, resourceKey: slot.resourceKey, ip }));
} catch (e) {
return Response.json({ ok: false, reason: "error", message: e instanceof Error ? e.message : "error" });
}
}
公開フォーム(client)は @event/booking を import せずこの route に POST する(server-only barrel を client に載せない)。admin /admin/booking は isReservationConfigured() の時だけ reservationStore.getSlotAvailability(slotId) と listReservations() で残席・一覧を出し、未接続は概念表示に degrade する。
見積書 PDF(QuoteDocument + exportPdf)
発行済みの見積を A4 縦の御見積書 PDF にする。帳票 component は @event/booking/ui(barrel と別の サブパス)にあり、書き出しは @event/asset-kit/capture の exportPdf に委譲する(自前 jsPDF は持たない)。
重要 — なぜ ./ui サブパス: @event/booking の barrel(.)は @vercel/blob / drizzle / node:crypto を含む server-only。帳票 UI を client から使えるよう、UI だけ @event/booking/ui に 分離してある。QuoteDocument は runtime import ゼロ(型のみ)の純 React なので server / client どちらでも描画でき、client(下記 CertificatePanel)が同じ要素を exportPdf に渡して焼く。
発行者ブロックの config(lib/booking.ts に追記)
import type { QuoteIssuer } from "@event/booking/ui"; // type-only = server 依存を持ち込まない
/** 見積書の発行者(自社)。ここを書き換えると帳票の発行者・押印が追従する(UI にハードコードしない)。 */
export const BOOKING_ISSUER: QuoteIssuer = {
name: "株式会社スターター",
address: "東京都渋谷区〇〇 1-2-3",
tel: "03-0000-0000",
email: "sales@example.com",
sealText: "S", // あれば朱印風の丸枠が発行者名の右に出る
};
CertificatePanel(client — 選択 → プレビュー → A4 PDF)
apps/<app>/src/app/admin/booking/CertificatePanel.tsx:
"use client";
import { useState, type CSSProperties } from "react";
import { QuoteDocument, type QuoteIssuer, type QuoteRecord } from "@event/booking/ui";
import { exportPdf } from "@event/asset-kit/capture";
const A4_W = 794; // A4 幅 @96dpi (210mm)
const A4_H = 1123; // A4 高 @96dpi (297mm)
const PREVIEW_SCALE = 0.42;
export function CertificatePanel({ quotes, issuer }: { quotes: QuoteRecord[]; issuer: QuoteIssuer }) {
const [selectedId, setSelectedId] = useState<string>(quotes[0]?.id ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const selected = quotes.find((q) => q.id === selectedId) ?? quotes[0] ?? null;
async function onExport() {
if (!selected || busy) return;
setBusy(true); setError(null);
try {
await exportPdf(
<QuoteDocument record={selected} issuer={issuer} />,
// w/h は撮影キャンバスの px、pageMm が PDF の物理サイズ(A4 縦)。
{ w: A4_W, h: A4_H, exportScale: 2, pageMm: [210, 297] },
"#ffffff",
selected.estimateNo,
);
} catch (e) {
setError(e instanceof Error ? e.message : "PDF 書き出しに失敗しました");
} finally { setBusy(false); }
}
if (quotes.length === 0) return <p>発行済みの見積がありません。</p>;
return (
<div style={{ display: "grid", gap: 16 }}>
<select value={selected?.id ?? ""} onChange={(e) => setSelectedId(e.target.value)}>
{quotes.map((q) => (
<option key={q.id} value={q.id}>
{q.estimateNo} — {q.company}(¥{q.totals.total.toLocaleString("ja-JP")})
</option>
))}
</select>
{selected ? (
<div style={{ width: A4_W * PREVIEW_SCALE, height: A4_H * PREVIEW_SCALE, overflow: "hidden", border: "1px solid #ddd" }}>
<div style={{ width: A4_W, height: A4_H, transform: `scale(${PREVIEW_SCALE})`, transformOrigin: "top left" } as CSSProperties}>
<QuoteDocument record={selected} issuer={issuer} />
</div>
</div>
) : null}
<div>
<button type="button" onClick={onExport} disabled={busy || !selected}>
{busy ? "書き出し中…" : "A4 PDF 書き出し"}
</button>
{error ? <span style={{ marginLeft: 12, color: "#c8322b" }}>{error}</span> : null}
</div>
</div>
);
}
(starter の実ファイルはプレビューに影・--doc-* 変数対応を足した装飾版。ここでは配線が分かる最小形。 QuoteDocument の props は { record: QuoteRecord; issuer: QuoteIssuer; title?: string }。色は帳票側の --doc-accent / --doc-seal 等の CSS 変数を祖先で定義すれば差し替えられる。)
admin ページに組み込む(既存表示は温存)
apps/<app>/src/app/admin/booking/page.tsx(server component)で見積一覧の下に足す:
import { BOOKING_ISSUER, quoteStore } from "@/lib/booking";
import { CertificatePanel } from "./CertificatePanel";
const quotes = await quoteStore.listQuotes().catch(() => []);
// …既存の一覧表の後ろに:
<CertificatePanel quotes={quotes} issuer={BOOKING_ISSUER} />
quoteStore.listQuotes() は server で呼び、QuoteRecord[] を client の CertificatePanel に props で渡す (barrel を client に持ち込まない)。書き出しは modern-screenshot(foreignObject)で画面と同一に焼くので、 プレビューと PDF が乖離しない。
完了条件
yarn workspace <app> typecheck(cast を入れれば green)- 見積:
/bookingで試算 → 見積番号採番 →/admin/bookingに一覧(DB 不要) - 見積書 PDF:
/admin/bookingで見積を選び「A4 PDF 書き出し」→<estimateNo>.pdf(A4 縦・物理 mm)が落ちる - 予約: add-database + 0002 migration 適用 →
/booking予約フォーム送信 →/admin/bookingの残席が減る →
capacity 超で waitlist → 同 slot×同 email の二重送信は partial-unique index で 1 件に収束