跳到主要內容

FE Day 34 預約流程與表單

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,後端契約對照來源。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...