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

予約 & 見積を追加する

見積(computeQuote / 純関数・DB 不要)をイベント app に配線し、公開見積フォーム + 見積計算 API + admin 一覧を動かす。予約 & キャンセル待ちは add-database 後に有効化する

前提: new-event解説 ch.29解説 ch.30module: 予約 & 見積

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

設計の要点: @event/booking は 2 系統。

まず見積だけで 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.tstranspilePackages"@event/booking" を追加(starter は両方済み)。

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

booking: true,

FEATURES に booking descriptor が無ければ追加する(nav に公開 /booking/admin/booking、env に BOOKING_OPEN、予約を使うなら DATABASE_URLrequired: 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.tsserver 専用。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.tsxclient — @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 で必ず動く):

  1. /booking を開く → 数量を変えると小計/消費税/合計が即時試算される(/api/booking/quote が computeQuote で計算)
  2. 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, ... } }
  3. 「見積番号を発行」→ Q<YYYYMMDD>-XXXX が採番され、dev では content/quotes.json に保存される
  4. /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.mjsdirapps/<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/bookingisReservationConfigured() の時だけ reservationStore.getSlotAvailability(slotId)listReservations() で残席・一覧を出し、未接続は概念表示に degrade する。

見積書 PDF(QuoteDocument + exportPdf)

発行済みの見積を A4 縦の御見積書 PDF にする。帳票 component は @event/booking/ui(barrel と別の サブパス)にあり、書き出しは @event/asset-kit/captureexportPdf に委譲する(自前 jsPDF は持たない)。

重要 — なぜ ./ui サブパス: @event/booking の barrel(.)は @vercel/blob / drizzle / node:crypto を含む server-only。帳票 UI を client から使えるよう、UI だけ @event/booking/ui に 分離してある。QuoteDocumentruntime 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 が乖離しない。

完了条件

capacity 超で waitlist → 同 slot×同 email の二重送信は partial-unique index で 1 件に収束