FE Day 34 預約流程與表單
執行需求:CPU 可跑。今天進入貫穿專案「預約管理系統」第四天,要把 Day 31 規劃的 /bookings/new 從佔位頁變成完整的預約流程。客戶在 Day 33 的服務清單點下「查看時段」之後,會進到這頁:先挑一個時段,再填寫聯絡資料,最後確認送出。這條路徑是整個系統的轉換核心,任何一步卡住都代表生意流失。今天會用 React 的受控元件、zod 表單驗證與 TanStack Query 的 mutation 把流程串起來,並沿用 Day 32 的 Button、Input 與 mock 模式,沒有 FastAPI 後端也能完整走完一次預約。
引言
表單是最多人寫、也最容易寫壞的前端元件。常見的問題有六個:欄位沒有受控(資料打進去卻拿不到值)、驗證只做在送出路徑(使用者要按了才知道錯)、送出時可以連點(同一筆預約送兩次)、錯誤訊息出現在錯誤的地方(明明是 Email 格式錯,卻跳一個全域紅字)、送出中沒有回饋(使用者不知道按了有沒有生效)、以及沒有確認畫面(送出後直接跳走,客戶不確定到底成不成功)。今天會把這六件事一次處理完。
今天的設計目標有四個。第一,把「選服務、選時段、填資料、確認」拆成有次序的步驟,使用者永遠知道自己在哪一步、下一步要做什麼。第二,時段由後端供給(mock 模式由前端生成),已被預約的時段要明顯不可選。第三,所有欄位驗證在前端先跑一次,錯誤訊息緊貼對應欄位,後端回傳的驗證錯誤也能呈現。第四,送出期間鎖住按鈕,避免重複建立預約,成功後顯示摘要與預約編號。
實作範圍:app/bookings/new/page.tsx 是 Server Component,負責取回服務清單與第一個服務的初始時段;components/booking/BookingWizard.tsx 用一個步驟狀態機把流程串起來;SlotPicker 負責時段選擇;CustomerForm 負責資料填寫與驗證;BookingSummary 負責成功後的確認摘要。資料層新增 fetchSlots 與 createBooking 兩個函式,維持 Day 31 的「同一介面、mock 或 live 兩種實作」慣例。
原理:預約流程的狀態機與表單驗證策略
預約流程本質上是一個有限狀態機。使用者在任一時刻只處於四種狀態之一:slot(選時段)、details(填資料)、submitting(送出中)、done(完成)。把狀態畫清楚的好處是:每個畫面只負責一件事,不會出現「同一頁同時顯示選時段與填資料」的混亂;而且「使用者能不能按下一關」這件事可以由狀態推導,不需要散落各處的 disabled 判斷。
表單驗證有三種做法:即時驗證(每打一個字就檢查)、失焦驗證(離開欄位時檢查)、送出驗證(按下送出才檢查)。三種都有適用情境:即時驗證適合「密碼強度」這類需要邊打邊看回饋的欄位;失焦驗證適合一般文字欄位;送出驗證則是最後一道防線。今天的策略是「送出時一次驗證全部,錯誤一次顯示」,原因是預約表單欄位少、且使用者通常一次填完,即時驗證反而會在還沒打完時就跳紅字,造成干擾。
驗證規則用 zod 這個 schema 驗證套件集中定義。zod 的價值在於「同一份 schema 同時提供執行期驗證與 TypeScript 型別」:用 z.infer 就能從 schema 推出表單型別,不會出現「schema 改了、型別忘了改」的不一致。這跟 Day 36 之後要處理的後端 Pydantic 契約是同一個精神:驗證規則只寫一次。
| 驗證時機 | 適合欄位 | 缺點 |
|---|---|---|
| 即時(每個字) | 密碼強度、即時查詢 | 還沒打完就跳錯,干擾輸入 |
| 失焦(離開欄位) | 姓名、Email | 使用者可能一路跳過所有欄位 |
| 送出(本篇採用) | 欄位少的表單 | 錯誤要按送出才看得到 |
資料層:時段查詢與建立預約
先擴充型別。時段(slot)與建立預約的輸入(input)是今天新增的兩個型別。注意 CreateBookingInput 刻意只包含「客戶能決定的欄位」,不包含 id、status、reference 這類由系統產生的欄位——這些欄位交給後端決定,前端不應該自己捏造:
// lib/mock/types.ts(Day 34 擴充)
export type ServiceSlot = {
startAt: string;
endAt: string;
available: boolean;
};
export type CreateBookingInput = {
serviceId: string;
startAt: string;
customerName: string;
customerEmail: string;
customerPhone?: string;
notes?: string;
};
mock 模式的時段用一個可重現的生成函式產生:以服務的時長加緩衝當作間隔,從營業開始時間排到結束時間,週日公休,並固定把每天下午兩點標成「已被預約」當示範。因為規則固定,測試可以斷言「某天一定有幾個可預約時段」,不會因為隨機而時好時壞:
// lib/mock/slots.ts
// 依服務與起始日期產生可預約時段;規則固定,方便測試重現。
import type { Service, ServiceSlot } from "@/lib/mock/types";
const OPEN_HOUR = 10;
const CLOSE_HOUR = 18;
export function generateSlots(service: Service, from: Date, days: number): ServiceSlot[] {
const slots: ServiceSlot[] = [];
const step = service.durationMinutes + service.bufferMinutes;
for (let d = 0; d < days; d += 1) {
const day = new Date(from);
day.setDate(day.getDate() + d);
if (day.getDay() === 0) continue; // 週日公休
for (let minute = OPEN_HOUR * 60; minute + service.durationMinutes <= CLOSE_HOUR * 60; minute += step) {
const start = new Date(day);
start.setHours(0, minute, 0, 0);
const end = new Date(start.getTime() + service.durationMinutes * 60_000);
// 固定保留 14:00 這個時段當「已被預約」的示範。
slots.push({ startAt: start.toISOString(), endAt: end.toISOString(), available: minute !== 14 * 60 });
}
}
return slots;
}
時段查詢函式維持「mock 或 live」雙模式。live 分支對應 Day 31 契約的 GET /services/{id}/slots,並帶 from 與 to 兩個查詢參數:
// lib/api/slots.ts
import { API_MODE } from "@/lib/api-mode";
import { mockServices } from "@/lib/mock/data";
import { generateSlots } from "@/lib/mock/slots";
import { withDelay } from "@/lib/mock/with-delay";
import type { ServiceSlot } from "@/lib/mock/types";
export type FetchSlotsOptions = { from: string; days?: number };
export async function fetchSlots(serviceId: string, options: FetchSlotsOptions): Promise<ServiceSlot[]> {
const days = options.days ?? 7;
if (API_MODE === "mock") {
const service = mockServices.find((s) => s.id === serviceId);
if (!service) throw new Error(`Service ${serviceId} not found`);
return withDelay(generateSlots(service, new Date(options.from), days), 200);
}
const params = new URLSearchParams({ from: options.from, to: addDays(options.from, days) });
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE}/services/${serviceId}/slots?${params}`, {
cache: "no-store",
});
if (!res.ok) throw new Error(`fetchSlots failed: ${res.status}`);
return res.json();
}
function addDays(iso: string, days: number): string {
const date = new Date(iso);
date.setDate(date.getDate() + days);
return date.toISOString();
}
建立預約是今天唯一的寫入操作,也是最需要防守的地方。mock 版會先檢查同服務、同時段是否已經有人預約(衝突判斷),有衝突就丟出錯誤;這是「後端本來就會做、前端也該先示範」的一致性檢查:
// lib/api/bookings.ts(第一天建立;Day 36 會擴充成完整版)
import { API_MODE } from "@/lib/api-mode";
import { mockBookings, mockServices } from "@/lib/mock/data";
import { withDelay } from "@/lib/mock/with-delay";
import type { Booking, CreateBookingInput } from "@/lib/mock/types";
let sequence = mockBookings.length;
export async function createBooking(input: CreateBookingInput): Promise<Booking> {
if (API_MODE === "mock") {
const service = mockServices.find((s) => s.id === input.serviceId);
if (!service) throw new Error("找不到指定的服務");
const clash = mockBookings.some(
(b) => b.serviceId === input.serviceId && b.startAt === input.startAt && b.status !== "cancelled",
);
if (clash) throw new Error("這個時段已經被預約,請重新選擇");
sequence += 1;
const endAt = new Date(new Date(input.startAt).getTime() + service.durationMinutes * 60_000).toISOString();
const booking: Booking = {
id: `bk-${String(sequence).padStart(4, "0")}`,
reference: `BS-${input.startAt.slice(0, 10)}-${String(sequence).padStart(4, "0")}`,
customerId: "u-me",
customerName: input.customerName,
serviceId: service.id,
serviceName: service.name,
startAt: input.startAt,
endAt,
status: "pending",
notes: input.notes ?? "",
};
mockBookings.push(booking);
return withDelay(booking, 250);
}
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE}/bookings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`createBooking failed: ${res.status}`);
return res.json();
}
這兩個函式有兩個設計重點。第一,mock 版直接 push 進 mockBookings,所以建立完之後到「我的預約」頁面看得到剛剛那筆,多次操作狀態保持一致。第二,createBooking 刻意不做「前端自己產生 id 後就當成功」的樂觀更新,而是等函式回傳才顯示成功畫面;樂觀更新雖然感覺更快,但在「時段衝突」這種失敗率不低的操作上會造成「畫面先顯示成功、下一拍才跳出錯誤」的尷尬。
完整實作:表單驗證與流程元件
驗證規則集中在一個 zod schema。這份 schema 同時是執行期檢查與型別來源,欄位錯誤訊息也寫在這裡,未來要改成英文版或調整文案只要改一處:
// lib/booking/schema.ts
import { z } from "zod";
export const customerSchema = z.object({
customerName: z.string().trim().min(2, "請輸入至少 2 個字的姓名"),
customerEmail: z.string().trim().email("Email 格式不正確"),
customerPhone: z
.string()
.trim()
.regex(/^09\d{8}$/, "請輸入 10 碼手機號碼,例如 0912345678")
.or(z.literal("")),
notes: z.string().trim().max(200, "備註最多 200 字").or(z.literal("")),
});
export type CustomerFormValues = z.infer<typeof customerSchema>;
頁面本身是 Server Component。它讀取 serviceId 查詢參數、取回服務清單,並先把第一個服務的時段抓下來,讓首屏不會先空一秒才出現時段。這個「先帶初始資料」的做法與 Day 33 的清單頁一致:能 SSR 就 SSR,需要互動的部分才交給 client:
// app/bookings/new/page.tsx
import type { Metadata } from "next";
import { fetchServices } from "@/lib/api/services";
import { fetchSlots } from "@/lib/api/slots";
import { BookingWizard } from "@/components/booking/BookingWizard";
export const metadata: Metadata = { title: "建立預約 | 預約管理系統" };
type SearchParams = { serviceId?: string };
export default async function NewBookingPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams;
const services = await fetchServices();
const selected = services.find((s) => s.id === params.serviceId) ?? services[0];
const slots = selected ? await fetchSlots(selected.id, { from: new Date().toISOString() }) : [];
return (
<main className="mx-auto max-w-3xl px-4 py-section">
<h1 className="mb-section text-3xl font-bold text-text-primary">建立預約</h1>
<BookingWizard services={services} initialServiceId={selected?.id} initialSlots={slots} />
</main>
);
}
BookingWizard 是流程的大腦。它持有四個狀態:目前選的服務、目前選的時段、目前步驟、以及建立成功後的預約物件。送出用 useMutation 包住,成功就切到完成畫面,失敗就把錯誤訊息顯示在表單下方:
// components/booking/BookingWizard.tsx
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { createBooking } from "@/lib/api/bookings";
import { SlotPicker } from "./SlotPicker";
import { CustomerForm } from "./CustomerForm";
import { BookingSummary } from "./BookingSummary";
import type { Booking, CreateBookingInput, Service, ServiceSlot } from "@/lib/mock/types";
type Step = "slot" | "details" | "done";
type CustomerFormState = { customerName: string; customerEmail: string; customerPhone: string; notes: string };
export function BookingWizard({
services,
initialServiceId,
initialSlots,
}: {
services: Service[];
initialServiceId?: string;
initialSlots: ServiceSlot[];
}) {
const [serviceId, setServiceId] = useState(initialServiceId ?? services[0]?.id ?? "");
const [slot, setSlot] = useState<ServiceSlot | null>(null);
const [step, setStep] = useState<Step>("slot");
const [created, setCreated] = useState<Booking | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: (input: CreateBookingInput) => createBooking(input),
onSuccess: (booking) => {
setCreated(booking);
setStep("done");
},
onError: (error: unknown) => {
setFormError(error instanceof Error ? error.message : "建立預約失敗,請稍後再試");
},
});
function submit(form: CustomerFormState) {
if (!slot) return;
setFormError(null);
mutation.mutate({ serviceId, startAt: slot.startAt, ...form });
}
if (step === "done" && created) {
return <BookingSummary booking={created} service={services.find((s) => s.id === created.serviceId)} />;
}
return (
<div className="flex flex-col gap-section">
<ol className="flex gap-3 text-sm">
{(["slot", "details"] as Step[]).map((value, index) => (
<li key={value} className={value === step ? "font-semibold text-brand-700" : "text-text-muted"}>
{index + 1}. {value === "slot" ? "選擇時段" : "填寫資料"}
</li>
))}
</ol>
{step === "slot" ? (
<SlotPicker
services={services}
serviceId={serviceId}
initialSlots={initialSlots}
onServiceChange={setServiceId}
onSelect={(picked) => {
setSlot(picked);
setStep("details");
}}
/>
) : (
<CustomerForm
selectedSlot={slot}
submitting={mutation.isPending}
formError={formError}
onBack={() => setStep("slot")}
onSubmit={submit}
/>
)}
</div>
);
}
BookingWizard 有四個設計重點。第一,Step 用字串聯集定義,新增步驟時 TypeScript 會提醒所有需要處理的地方。第二,服務切換時只更新 serviceId,時段由 SlotPicker 自己的 query 重新抓取,責任單一。第三,成功後不再顯示表單,而是切到 BookingSummary,避免使用者重新整理後又送一次。第四,onError 把錯誤訊息統一收進 formError,顯示位置固定,不會到處亂跳。
時段選擇元件負責查詢與呈現。它用 TanStack Query 依 serviceId 取時段,並把同一時段按日期分組。被預約的時段用 disabled 呈現,讓使用者清楚看見「這個時間不行」,而不是選了才被拒絕:
// components/booking/SlotPicker.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchSlots } from "@/lib/api/slots";
import { formatSlotRange } from "@/lib/format";
import type { Service, ServiceSlot } from "@/lib/mock/types";
export function SlotPicker({
services,
serviceId,
initialSlots,
onSelect,
onServiceChange,
}: {
services: Service[];
serviceId: string;
initialSlots: ServiceSlot[];
onSelect: (slot: ServiceSlot) => void;
onServiceChange: (id: string) => void;
}) {
const { data: slots = initialSlots, isLoading } = useQuery({
queryKey: ["slots", serviceId],
queryFn: () => fetchSlots(serviceId, { from: new Date().toISOString() }),
enabled: Boolean(serviceId),
});
const grouped = slots.reduce<Record<string, ServiceSlot[]>>((acc, slot) => {
const day = slot.startAt.slice(0, 10);
(acc[day] ??= []).push(slot);
return acc;
}, {});
return (
<div className="flex flex-col gap-card">
<label className="flex flex-col gap-1 text-sm font-medium">
服務
<select
value={serviceId}
onChange={(e) => onServiceChange(e.target.value)}
className="h-10 rounded-button border border-surface-border bg-white px-3"
>
{services.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
{isLoading ? <p className="text-sm text-text-muted">載入時段中…</p> : null}
{Object.entries(grouped).map(([day, daySlots]) => (
<section key={day}>
<h3 className="mb-2 text-sm font-semibold text-text-primary">{day}</h3>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{daySlots.map((slot) => (
<button
key={slot.startAt}
type="button"
disabled={!slot.available}
onClick={() => onSelect(slot)}
className="rounded-button border border-surface-border px-3 py-2 text-sm hover:border-brand-500 hover:text-brand-700 disabled:cursor-not-allowed disabled:opacity-40"
>
{formatSlotRange(slot.startAt, slot.endAt)}
</button>
))}
</div>
</section>
))}
{slots.length === 0 && !isLoading ? (
<p className="text-sm text-text-secondary">這週沒有可預約的時段,請稍後再試。</p>
) : null}
</div>
);
}
資料填寫元件把 zod 驗證、欄位錯誤與送出鎖定都收在一起。safeParse 不丟例外,回傳成功或失敗的結果物件;失敗時我們把 zod 的 issue 陣列整理成「欄位名稱到訊息」的對照表,餵給 Day 32 Input 元件的 error prop:
// components/booking/CustomerForm.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { formatSlotRange } from "@/lib/format";
import { customerSchema } from "@/lib/booking/schema";
import type { ServiceSlot } from "@/lib/mock/types";
type FormState = { customerName: string; customerEmail: string; customerPhone: string; notes: string };
export function CustomerForm({
selectedSlot,
submitting,
formError,
onBack,
onSubmit,
}: {
selectedSlot: ServiceSlot | null;
submitting: boolean;
formError: string | null;
onBack: () => void;
onSubmit: (form: FormState) => void;
}) {
const [form, setForm] = useState<FormState>({ customerName: "", customerEmail: "", customerPhone: "", notes: "" });
const [errors, setErrors] = useState<Record<string, string>>({});
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const parsed = customerSchema.safeParse(form);
if (!parsed.success) {
const next: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const key = String(issue.path[0]);
if (!next[key]) next[key] = issue.message;
}
setErrors(next);
return;
}
setErrors({});
onSubmit(form);
}
const update = (patch: Partial<FormState>) => setForm((cur) => ({ ...cur, ...patch }));
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-card">
<p className="text-sm text-text-secondary">
時段:{selectedSlot ? formatSlotRange(selectedSlot.startAt, selectedSlot.endAt) : "尚未選擇"}
</p>
<Input label="姓名" value={form.customerName} error={errors.customerName} onChange={(e) => update({ customerName: e.target.value })} required />
<Input label="Email" type="email" value={form.customerEmail} error={errors.customerEmail} onChange={(e) => update({ customerEmail: e.target.value })} required />
<Input label="手機" hint="格式為 09 開頭共 10 碼" value={form.customerPhone} error={errors.customerPhone} onChange={(e) => update({ customerPhone: e.target.value })} />
<Input label="備註" value={form.notes} error={errors.notes} onChange={(e) => update({ notes: e.target.value })} />
{formError ? <p role="alert" className="text-sm text-danger-600">{formError}</p> : null}
<div className="flex justify-between">
<Button type="button" variant="ghost" onClick={onBack}>上一步</Button>
<Button type="submit" loading={submitting}>確認預約</Button>
</div>
</form>
);
}
這個表單有四個設計重點。第一,所有欄位都是受控元件,值存在 React state,送出時直接拿 state 驗證,不依賴 DOM。第二,safeParse 的錯誤整理成 map,同一個欄位只顯示第一則訊息,避免一次跳三行。第三,Button 的 loading 會自動 disable 並標記 aria-busy,這是防連點的第一層。第四,formError 用 role="alert",伺服器回傳的錯誤(例如時段衝突)會立刻被輔助科技朗讀。
最後是成功畫面。它不只是一句「成功」,而是把預約編號、服務、時段、狀態都列出來,並提供「查看我的預約」與「再訂一筆」兩個下一步:
// components/booking/BookingSummary.tsx
import Link from "next/link";
import { Badge } from "@/components/ui/Badge";
import { Card, CardBody, CardTitle } from "@/components/ui/Card";
import { formatSlotRange } from "@/lib/format";
import type { Booking, Service } from "@/lib/mock/types";
export function BookingSummary({ booking, service }: { booking: Booking; service?: Service }) {
return (
<Card>
<CardTitle>預約已送出</CardTitle>
<CardBody>
<p className="mt-2">我們已收到您的預約,確認後會以 Email 通知。</p>
<dl className="mt-4 flex flex-col gap-2 text-sm">
<div className="flex justify-between">
<dt className="text-text-muted">預約編號</dt>
<dd className="font-mono">{booking.reference}</dd>
</div>
<div className="flex justify-between">
<dt className="text-text-muted">服務</dt>
<dd>{service?.name ?? booking.serviceName}</dd>
</div>
<div className="flex justify-between">
<dt className="text-text-muted">時段</dt>
<dd>{formatSlotRange(booking.startAt, booking.endAt)}</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-text-muted">狀態</dt>
<dd><Badge tone="warning">待確認</Badge></dd>
</div>
</dl>
</CardBody>
<div className="mt-card flex gap-3">
<Link href="/bookings" className="rounded-button bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
查看我的預約
</Link>
<Link href="/services" className="rounded-button border border-surface-border px-4 py-2 text-sm font-medium text-text-primary hover:bg-surface-muted">
再訂一筆
</Link>
</div>
</Card>
);
}
驗證:實際走一次預約流程
啟動 pnpm dev 後,從 /services 點「查看時段」進到單一服務,再點「查看時段」或直接開 /bookings/new?serviceId=svc-001,就能走完整條流程。檢查重點有五個:時段清單是否依服務時長排列(攝影 60 分鐘的間隔會比語言家教 30 分鐘寬)、14:00 那個時段是否呈現灰色不可選、姓名只填一個字時是否跳出「請輸入至少 2 個字」、Email 填錯格式時錯誤是否出現在 Email 欄位下方、以及送出後是否看到帶有預約編號的成功畫面。
再驗證一次防連點:把網路速度調慢(瀏覽器開發者工具的 Network 設為 Slow 3G),在送出後連續點「確認預約」。正確行為是第一次點選後按鈕立刻變成載入中並失效,之後的點選不會再送出。這條如果沒做,客戶在慢網路上會不小心建立兩筆一樣的預約,事後還要請對方取消。
常見錯誤與踩雷
第一個踩雷是「用非受控欄位然後靠 DOM 取值」。如果表單欄位沒有 value 與 onChange,送出時要靠 ref 一個一個抓,React 沒辦法預測畫面,驗證與重設也會變複雜。修法:一律用受控元件,值放 state,今天的 CustomerForm 就是這樣寫的。
第二個踩雷是「送出按鈕沒有防連點」。使用者等不及時會連點三下,送出三個一樣的請求。修法有兩層:UI 層用 loading 讓按鈕失效,資料層則應該帶 idempotency key 讓後端辨識重複請求。今天先做 UI 層,資料層的冪等鍵留待 Day 36 串接真實 API 時補上。
第三個踩雷是「時區沒處理」。後端通常回傳含時區的 ISO 字串(例如 2026-04-20T06:00:00+00:00),如果直接用字串切割顯示,會發現「明明選下午兩點,畫面卻顯示早上六點」。修法:顯示一律用 Intl.DateTimeFormat 指定 timeZone: "Asia/Taipei",今天的 formatSlotRange 已經處理。
第四個踩雷是「成功後沒有給下一步」。如果只顯示「已送出」就沒了,客戶會不確定要去哪裡看自己的預約。修法:成功畫面至少給「查看我的預約」與「回到服務清單」兩個連結,今天兩者都放進 BookingSummary。
第五個踩雷是「用 alert() 顯示錯誤」。alert 會中斷整個瀏覽器、在手機上體驗尤其差,而且無法樣式化。修法:欄位錯誤用 Input 的 error prop,全域錯誤用內嵌訊息或 Day 37 會做的 Toast;兩者都是頁面內的元素,不打斷使用者。
效能與實務提醒
這一頁的程式碼量不大,但有一個值得注意的效能點:時段查詢用 queryKey: ["slots", serviceId],同一個服務的時段在 staleTime 內會走快取,使用者切來切去不會反覆打後端。等 Day 36 串上真實 API,這個快取設定就是「不要每換一次服務都重打一次」的關鍵。
另一個實務提醒是「時段的最小單位」。今天的 mock 以「服務時長加緩衝」當間隔,所以攝影 60 分鐘加 15 分鐘緩衝,實際間隔是 75 分鐘。真實系統的緩衝設定通常由後端決定(避免兩個預約之間沒有收尾時間),前端只負責把後端給的時段顯示出來、不要把 available 為 false 的時段算進「還剩幾個」。這條分工在 Day 37 的錯誤處理與 Day 40 的驗收都會再被檢驗。
最後提醒表單的無障礙。每個欄位都要有真正的 label,錯誤訊息要與欄位以 aria-describedby 關聯(Day 32 的 Input 已經做好),required 欄位要讓輔助科技知道。這些細節不會讓畫面變漂亮,但會讓使用讀屏軟體的客戶也能完成預約。
小結
今天把 /bookings/new 從佔位頁變成完整的預約流程。我們定義了 ServiceSlot 與 CreateBookingInput 兩個型別、寫了 fetchSlots 與 createBooking 兩個資料層函式、用 zod 集中驗證規則,並用 BookingWizard 把「選時段、填資料、確認」串成一個狀態機。整條流程維持 Day 31 的介面慣例:mock 或 live 只差環境變數,元件程式碼不變。防連點、時區顯示、成功後的下一步這三個容易被忽略的細節也都處理了。
結語
預約流程是整個系統「把訪客變成客戶」的那一步,它值得比清單頁更高的標準對待。今天我們把表單驗證、狀態機、防重複送出與成功回饋一次做完,明天 Day 35 要把鏡頭轉到管理端:建立 admin layout、實作「今日預約總覽」與「預約管理清單」,讓服務提供者能確認、取消與管理今天早上剛進來的每一筆預約。讀完這篇你應該能回答:預約流程的狀態機怎麼切?表單驗證要在什麼時機跑?為什麼成功後不該直接跳走?防連點為什麼要分 UI 與資料兩層?
延伸資源
- React 受控元件:
https://react.dev/reference/react-dom/components/input,表單受控元件的官方說明。 - zod 官方文件:
https://zod.dev/,schema 驗證與z.infer型別推導。 - TanStack Query 5.x
useMutation:https://tanstack.com/query/latest/docs/framework/react/guides/mutations,寫入操作的標準用法。 - MDN
Intl.DateTimeFormat:https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat,時區與格式在地化。 - Web 系列 Day 37 的預約流程 API:
https://blog.hao-code.com/2025/07/web-day-37.html,後端契約對照來源。
留言
張貼留言