FE Day 33 預約列表與篩選介面
執行需求:CPU 可跑。今天進入貫穿專案「預約管理系統」第三天。Day 31 定好路由與 API 契約、Day 32 建好設計系統,今天開始把規劃中的頁面一個一個填實,第一個上場的是公開的 /services「服務清單與篩選介面」。這頁是客戶進入系統後第一個真正有互動的畫面:要能一眼看懂有哪些服務、用搜尋與篩選把範圍縮小、點進單一服務看可預約時段。今天全程沿用 Day 32 的 Button、Input、Card、Badge 與 mock 模式,沒有啟動 FastAPI 也能完整跑起來。
引言
清單頁是前端專案最容易寫壞的畫面。常見的四種錯誤是:把所有資料一次攤開(沒有篩選、沒有載入狀態)、篩選條件寫死(後端新增條件時前端要跟著改)、搜尋與篩選互相打架(兩者算出來的結果不一致)、以及空狀態沒有設計(使用者分不清「還在載入」與「真的沒資料」)。這四個問題在日常流量下也許不明顯,但當服務品項從 3 個長到 30 個,客戶就會開始抱怨「找不到我要的服務」。
今天的設計目標有四個。第一,客戶進站 5 秒內能找到想要的服務。第二,篩選條件直覺,主條件不超過三個(類型、時長、價格),避免「篩選面板比清單還長」。第三,當前篩選狀態反映在網址上,可以分享、可以加書籤、按上一頁會還原。第四,載入、空白、失敗三種狀態都有明確的視覺回饋。我們會用 Next.js 15 App Router 的 URL search params 當作篩選狀態的來源,讓「篩選條件、網址、畫面」三者保持一致。
實作範圍固定如下:app/services/page.tsx 是 Server Component,負責初始資料載入;components/booking/ServicesList.tsx 是 Client Component,負責把篩選面板與清單串起來;components/booking/ServiceFilters.tsx 是純展示的篩選元件;ServiceCard 負責單一服務卡片的視覺。資料層沿用 Day 31 的 fetchServices(),並在今天把它擴充成支援篩選參數。
原理:URL 驅動的篩選狀態
篩選狀態有兩種常見的管理方式:本地 state(useState)與網址 state(useSearchParams)。本地 state 寫起來最簡單,但有兩個致命缺點:篩選結果無法分享(「我找到三個便宜的服務」只能截圖),而且使用者按上一頁會直接失去剛剛的篩選。網址 state 把條件寫進 query string(例如 /services?q=攝影&maxPriceCents=200000),既可分享也可加書籤,上一頁與重新整理都會還原。
Next.js 15 對網址 state 的支援很完整:Server Component 可以直接讀 searchParams prop,Client Component 可以用 useSearchParams 這個鉤子。我們把篩選條件寫進網址,並用 router.replace() 更新(不污染瀏覽紀錄),再用 useTransition 標記過渡狀態,讓畫面在更新網址時不會卡頓。
第二個決策是「前端篩選還是伺服器端篩選」。資料量小於 100 筆時,在 client 端篩選最快,因為不必多一趟網路往返;資料量大於上千筆時,就必須交給伺服器端,否則每次載入都要傳送整包資料。預約系統的服務清單通常小於 50 筆,所以我們走 client 端篩選。但資料層介面統一設計成「接受 filter 參數」,未來服務數量長大,只要把 fetchServices 的 live 分支改成帶 query string,元件完全不用改。
| 比較面向 | 本地 state | 網址 state(本篇採用) |
|---|---|---|
| 可否分享 | 不可 | 可以,複製網址即可 |
| 上一頁行為 | 失去篩選 | 還原篩選 |
| 重新整理 | 回到預設 | 保留條件 |
| 實作成本 | 低 | 中,需要同步網址 |
資料層:擴充型別與 fetchServices
Day 31 定義的 Service 只有名稱、描述、時長、緩衝、價格與啟用狀態。要做分類篩選,我們在今天替型別加上 category 欄位。這個欄位是列舉型別,讓 TypeScript 在編譯期就幫我們檢查「有沒有填錯分類」:
// lib/mock/types.ts(Day 33 擴充)
// Day 31 已定義 Service 的核心欄位;今天加上 category 以支援分類篩選。
export type ServiceCategory = "photography" | "fitness" | "language" | "consulting";
export type Service = {
id: string;
name: string;
description: string;
category: ServiceCategory;
durationMinutes: number;
bufferMinutes: number;
priceCents: number;
isActive: boolean;
};
型別補上之後,也別忘了在 lib/mock/data.ts 為每個示範服務填上對應的分類,例如攝影服務填 photography、健身服務填 fitness。如果只改了型別卻沒補資料,mock 模式下依分類篩選會一個都找不到,這種「程式沒報錯、畫面卻空掉」的情況就是資料與型別脫鉤的典型症狀。
接著把顯示用的格式化函式集中到 lib/format.ts。這件事看似瑣碎,但它是「金額與時間只在一處定義」的關鍵:如果不集中,某一天就會出現「卡片顯示 4,500 元、明細顯示 4500.0 元」的不一致。金額統一以「分」為內部單位(整數運算不會有浮點誤差),顯示時才除以 100 並套上台灣的貨幣格式。
// lib/format.ts
// 全站共用的格式化工具,不要把 Intl 散落在元件裡。
const TAIPEI = "Asia/Taipei";
export function formatPrice(cents: number): string {
return new Intl.NumberFormat("zh-TW", {
style: "currency",
currency: "TWD",
maximumFractionDigits: 0,
}).format(cents / 100);
}
export function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes} 分鐘`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest === 0 ? `${hours} 小時` : `${hours} 小時 ${rest} 分`;
}
export function formatSlotRange(startAt: string, endAt: string): string {
const fmt = new Intl.DateTimeFormat("zh-TW", {
timeZone: TAIPEI,
month: "numeric",
day: "numeric",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
return `${fmt.format(new Date(startAt))} - ${fmt.format(new Date(endAt))}`;
}
格式化完成後,擴充 fetchServices 讓它接受篩選參數。這裡刻意維持 Day 31 的形狀:mock 分支回傳前端濾過的資料,live 分支把條件組進 query string。篩選邏輯抽成純函式 applyFilter,不依賴任何外部狀態,因此可以直接單元測試:
// lib/api/services.ts(擴充篩選支援)
import { API_MODE } from "@/lib/api-mode";
import { mockServices } from "@/lib/mock/data";
import { withDelay } from "@/lib/mock/with-delay";
import type { Service, ServiceCategory } from "@/lib/mock/types";
export type ServiceFilter = {
q?: string;
category?: ServiceCategory;
maxDuration?: number;
maxPriceCents?: number;
};
export async function fetchServices(filter: ServiceFilter = {}): Promise<Service[]> {
if (API_MODE === "mock") {
return withDelay(applyFilter(mockServices, filter), 150);
}
const params = new URLSearchParams();
if (filter.q) params.set("q", filter.q);
if (filter.category) params.set("category", filter.category);
if (filter.maxDuration) params.set("maxDuration", String(filter.maxDuration));
if (filter.maxPriceCents) params.set("maxPriceCents", String(filter.maxPriceCents));
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE}/services?${params}`, {
cache: "no-store",
});
if (!res.ok) throw new Error(`fetchServices failed: ${res.status}`);
return res.json();
}
export function applyFilter(services: Service[], filter: ServiceFilter): Service[] {
const q = filter.q?.trim().toLowerCase();
return services
.filter((s) => s.isActive)
.filter((s) => !q || s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q))
.filter((s) => !filter.category || s.category === filter.category)
.filter((s) => !filter.maxDuration || s.durationMinutes <= filter.maxDuration)
.filter((s) => !filter.maxPriceCents || s.priceCents <= filter.maxPriceCents);
}
這段程式有三個關鍵設計。第一,filter 是 optional,沒給就回傳全部;給了就在 mock 端(前端)或 live 端(後端)套用,呼叫端永遠只看到一種介面。第二,applyFilter 是純函式,輸入陣列與條件、回傳新陣列,不改動原始資料,這讓「篩選結果錯誤」這類 bug 能被單元測試一次鎖住。第三,live 分支刻意用原生 fetch,因為統一的 apiClient 要等 Day 36 才建立;今天的重點是篩選邏輯,先不引入新的抽象層。等到 Day 36,這段會換成 apiFetch("/services", { query: filter }),元件端一行都不用改。
完整實作:Server Component 與清單元件
頁面本身寫成 Server Component,負責在伺服器端先取回資料,讓首屏 HTML 直接帶內容。因為 ServicesList 會用到 useSearchParams,在 Next.js 15 的靜態渲染階段需要一層 Suspense 邊界,我們用 Suspense 包住它並提供骨架作為 fallback:
// app/services/page.tsx
import type { Metadata } from "next";
import { Suspense } from "react";
import { fetchServices } from "@/lib/api/services";
import { ServicesList } from "@/components/booking/ServicesList";
export const metadata: Metadata = {
title: "預約服務 | 預約管理系統",
description: "瀏覽所有可預約的服務,選擇適合您的時段",
};
export default async function ServicesPage() {
// 一次取回全部服務;服務數量小時,交由 client 端篩選最快。
const services = await fetchServices();
return (
<main className="mx-auto max-w-6xl px-4 py-section">
<header className="mb-section">
<h1 className="text-3xl font-bold text-text-primary">預約服務</h1>
<p className="mt-2 text-text-secondary">選擇您想預約的服務,查看可預約時段。</p>
</header>
<Suspense fallback={<ServicesSkeleton />}>
<ServicesList services={services} />
</Suspense>
</main>
);
}
function ServicesSkeleton() {
return <div className="h-10 w-full animate-pulse rounded-card bg-surface" />;
}
清單元件是今天的主角。它同時負責三件事:把篩選條件同步到網址、依條件算出要顯示的服務、以及處理三種狀態。我們用 useDeferredValue 讓搜尋輸入保持流暢,用 useMemo 確保只有在條件或資料變動時才重算清單,並用 useTransition 把網址更新標記成過渡更新:
// components/booking/ServicesList.tsx
"use client";
import { useDeferredValue, useMemo, useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/Input";
import { applyFilter, type ServiceFilter } from "@/lib/api/services";
import type { Service, ServiceCategory } from "@/lib/mock/types";
import { ServiceFilters } from "./ServiceFilters";
import { ServiceCard } from "./ServiceCard";
function toNumber(value: string | null): number | undefined {
return value ? Number(value) : undefined;
}
export function ServicesList({ services }: { services: Service[] }) {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [text, setText] = useState(searchParams.get("q") ?? "");
// 輸入框立刻更新,但套用到清單的值延後一拍,避免每個字都重算。
const deferredText = useDeferredValue(text);
const filter = useMemo<ServiceFilter>(
() => ({
q: deferredText || undefined,
category: (searchParams.get("category") as ServiceCategory) ?? undefined,
maxDuration: toNumber(searchParams.get("maxDuration")),
maxPriceCents: toNumber(searchParams.get("maxPriceCents")),
}),
[deferredText, searchParams],
);
const visible = useMemo(() => applyFilter(services, filter), [services, filter]);
function updateUrl(patch: Partial<ServiceFilter>) {
const next = new URLSearchParams(searchParams.toString());
const merged = { ...filter, ...patch };
const write = (key: string, value: string | number | undefined) => {
if (value === undefined || value === "") next.delete(key);
else next.set(key, String(value));
};
write("q", merged.q);
write("category", merged.category);
write("maxDuration", merged.maxDuration);
write("maxPriceCents", merged.maxPriceCents);
startTransition(() => {
router.replace(`/services?${next.toString()}`, { scroll: false });
});
}
return (
<div className="grid gap-section lg:grid-cols-[280px_1fr]">
<aside className="flex flex-col gap-card">
<Input
label="搜尋"
type="search"
placeholder="輸入服務名稱或描述"
value={text}
onChange={(e) => {
setText(e.target.value);
updateUrl({ q: e.target.value });
}}
/>
<ServiceFilters value={filter} onChange={updateUrl} />
</aside>
<section aria-busy={isPending} aria-live="polite">
<p className="mb-3 text-sm text-text-muted">共 {visible.length} 項服務</p>
{visible.length === 0 ? (
<EmptyState query={text} />
) : (
<ul className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{visible.map((service) => (
<li key={service.id}>
<ServiceCard service={service} />
</li>
))}
</ul>
)}
</section>
</div>
);
}
function EmptyState({ query }: { query: string }) {
return (
<div className="rounded-card border border-dashed border-surface-border p-12 text-center">
<p className="text-text-secondary">
{query ? `找不到符合「${query}」的服務` : "目前沒有可預約的服務"}
</p>
<p className="mt-2 text-sm text-text-muted">試試調整篩選條件,或清除搜尋。</p>
</div>
);
}
這個元件有五個設計重點。第一,useDeferredValue 讓輸入框的值立即更新,但套用到清單的值延後一拍,打長字串時不會每個字都重算。第二,useMemo 有明確的依賴陣列,只在條件變動時重算,避免每次 render 都重新跑 applyFilter。第三,router.replace() 而非 push(),不會讓每次打字都塞一筆瀏覽紀錄;{ scroll: false } 避免畫面跳回頂端。第四,aria-busy 與 aria-live 讓螢幕閱讀器知道「清單正在更新」(這是 Day 28 的無障礙細節)。第五,空狀態會根據「有沒有輸入關鍵字」給不同訊息,並提示下一步,而不是只丟一句「沒有資料」。
篩選面板與服務卡片
篩選面板是純展示元件:接收當前的 filter 與一個 onChange,把使用者的選擇往上拋。所有下拉選項都由常數陣列產生,未來新增分類或價格級距只要改陣列,不用動 JSX:
// components/booking/ServiceFilters.tsx
"use client";
import { useId } from "react";
import type { ServiceFilter } from "@/lib/api/services";
import type { ServiceCategory } from "@/lib/mock/types";
type Props = {
value: ServiceFilter;
onChange: (next: Partial<ServiceFilter>) => void;
};
const CATEGORIES: { value: ServiceCategory; label: string }[] = [
{ value: "photography", label: "攝影" },
{ value: "fitness", label: "健身" },
{ value: "language", label: "語言" },
{ value: "consulting", label: "諮詢" },
];
const MAX_DURATIONS = [30, 60, 90, 120];
const MAX_PRICES = [500, 1000, 2000, 5000];
const selectClass =
"h-10 w-full rounded-button border border-surface-border bg-white px-3 text-base " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500";
export function ServiceFilters({ value, onChange }: Props) {
const catId = useId();
const durId = useId();
const priceId = useId();
return (
<div className="space-y-4 rounded-card border border-surface-border bg-surface p-card">
<div>
<label htmlFor={catId} className="mb-1 block text-sm font-medium text-text-primary">服務類型</label>
<select
id={catId}
className={selectClass}
value={value.category ?? ""}
onChange={(e) => onChange({ category: (e.target.value || undefined) as ServiceCategory | undefined })}
>
<option value="">全部類型</option>
{CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div>
<label htmlFor={durId} className="mb-1 block text-sm font-medium text-text-primary">最長時長</label>
<select
id={durId}
className={selectClass}
value={value.maxDuration ?? ""}
onChange={(e) => onChange({ maxDuration: e.target.value ? Number(e.target.value) : undefined })}
>
<option value="">不限</option>
{MAX_DURATIONS.map((m) => <option key={m} value={m}>{m} 分鐘以內</option>)}
</select>
</div>
<div>
<label htmlFor={priceId} className="mb-1 block text-sm font-medium text-text-primary">最高價格</label>
<select
id={priceId}
className={selectClass}
value={value.maxPriceCents ? value.maxPriceCents / 100 : ""}
onChange={(e) => onChange({ maxPriceCents: e.target.value ? Number(e.target.value) * 100 : undefined })}
>
<option value="">不限</option>
{MAX_PRICES.map((p) => <option key={p} value={p}>{p.toLocaleString("zh-TW")} 元以下</option>)}
</select>
</div>
<button
type="button"
onClick={() => onChange({ q: undefined, category: undefined, maxDuration: undefined, maxPriceCents: undefined })}
className="w-full rounded-button border border-surface-border bg-white px-4 py-2 text-sm font-medium text-text-primary hover:bg-surface-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
清除全部篩選
</button>
</div>
);
}
篩選面板有三個設計重點。第一,每個下拉都有 label 與 htmlFor 明確綁定,點標籤可以聚焦到欄位、螢幕閱讀器會唸出標籤。第二,useId 產生 SSR 安全的識別碼,避免伺服器與瀏覽器端渲染出不同 id 造成 hydration 警告。第三,價格用「元」當顯示單位、內部轉成「分」,使用者不必理解「分」是什麼,程式端又保有整數運算的好處。這條「顯示單位與儲存單位分離」的規則在之後的金流情境同樣重要。
最後是服務卡片。它是整個頁面的視覺重心,每張卡顯示名稱、兩行描述、時長、緩衝時間、價格與主要行動按鈕。用 Card 與 Badge 這兩個 Day 32 的基礎元件組合,不另外寫死樣式:
// components/booking/ServiceCard.tsx
import Link from "next/link";
import type { Service } from "@/lib/mock/types";
import { Card, CardBody, CardTitle } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { formatDuration, formatPrice } from "@/lib/format";
export function ServiceCard({ service }: { service: Service }) {
return (
<Card className="flex h-full flex-col">
<CardTitle>{service.name}</CardTitle>
<CardBody>
<p className="line-clamp-2 text-sm">{service.description}</p>
</CardBody>
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-text-secondary">
<Badge tone="info">{formatDuration(service.durationMinutes)}</Badge>
{service.bufferMinutes > 0 ? (
<Badge tone="neutral">含 {service.bufferMinutes} 分鐘緩衝</Badge>
) : null}
</div>
<div className="mt-card flex items-center justify-between">
<span className="text-xl font-semibold text-text-primary">{formatPrice(service.priceCents)}</span>
<Link
href={`/services/${service.id}`}
className="rounded-button bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2"
>
查看時段
</Link>
</div>
</Card>
);
}
卡片有三個設計重點。第一,line-clamp-2 限制描述最多兩行,避免某個服務的描述特別長、把整排卡片撐高。第二,用 Badge 顯示時長與緩衝,讓客戶一眼看出這個服務需要投入多少時間。第三,「查看時段」是主要行動,用品牌色 bg-brand-600;價格雖然視覺重量高,但行動按鈕才是要引導點選的目標,所以放在卡片右下角,與價格形成視覺動線。
載入狀態與單元測試
載入狀態有兩層。第一層是路由層:Next.js 15 會自動使用同目錄下的 loading.tsx 作為該路由的 Suspense fallback。第二層是元件層:清單更新時用 aria-busy 標記。我們補上 app/services/loading.tsx,用骨架畫面取代傳統的置中轉圈,避免版面在資料到達時大幅跳動:
// app/services/loading.tsx
// Next.js 15 會在 Suspense 邊界顯示這個檔案,直到 Server Component 完成。
export default function ServicesLoading() {
return (
<main className="mx-auto max-w-6xl px-4 py-section">
<div className="mb-section h-9 w-48 animate-pulse rounded bg-surface" />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="h-48 animate-pulse rounded-card bg-surface" />
))}
</div>
</main>
);
}
骨架的關鍵是「形狀要接近最終畫面」。如果骨架是一條置中的細線,資料到達時整個版面會重排,使用者會感覺畫面「跳了一下」。今天的骨架先放標題與六張卡片的占位塊,與真實版面同高,切換時幾乎無感。
最後把篩選邏輯用單元測試鎖住。因為 applyFilter 是純函式,測試不需要渲染任何元件,跑得又快又穩:
// tests/services-filter.test.ts
import { describe, expect, it } from "vitest";
import { applyFilter } from "@/lib/api/services";
import { mockServices } from "@/lib/mock/data";
describe("applyFilter", () => {
it("只回傳啟用中的服務", () => {
const result = applyFilter(mockServices, {});
expect(result.every((s) => s.isActive)).toBe(true);
});
it("依關鍵字比對名稱與描述", () => {
const result = applyFilter(mockServices, { q: "攝影" });
expect(result.map((s) => s.id)).toContain("svc-001");
});
it("依價格上限過濾", () => {
const result = applyFilter(mockServices, { maxPriceCents: 500000 });
expect(result.every((s) => s.priceCents <= 500000)).toBe(true);
});
it("空條件回傳全部啟用服務", () => {
expect(applyFilter(mockServices, {}).length).toBeGreaterThan(0);
});
});
常見錯誤與踩雷
第一個踩雷是「篩選條件只放 useState、不寫網址」。這樣使用者篩完想分享給朋友只能截圖,按上一頁又會回到全部服務。修法:所有篩選條件都寫進 URL search params,並用 router.replace 同步;這樣分享、書籤、上一頁、重新整理四個情境都會正確。
第二個踩雷是「搜尋框每打一個字就打 API」。這在資料量大時會打爆伺服器,畫面也會因為反覆重渲染而卡頓。修法有兩條:資料小時用 useDeferredValue 把套用時機延後(今天的做法),或資料大時加 debounce(例如 300 毫秒內的輸入合併成一次查詢)。兩者不衝突,可以一起用。
第三個踩雷是「在元件裡直接對 prop 陣列做 filter 又改動它」。像 services.sort(...) 會就地改動原始陣列,導致資料層的快取被汙染,別的頁面看到的順序也跟著變。修法:applyFilter 一律回傳新陣列,需要排序時先用展開運算子複製再排。這條在共用快取(例如 TanStack Query)的情境尤其重要。
第四個踩雷是「篩選後畫面跳回頂端」。使用者好不容易捲到第三排,改一個條件就回到最上面,體驗很差。修法:router.replace 的第二個參數帶 { scroll: false },Next.js 15 會保留捲動位置。
第五個踩雷是「空狀態與錯誤狀態長得一樣」。兩者都是「沒有任何卡片」,但一個是「系統壞了」、一個是「條件太嚴」。修法:空狀態用虛線邊框與低彩度呈現,錯誤狀態用紅色與重試按鈕,並在文字上明確區分「目前沒有符合條件的服務」與「載入失敗,請稍後重試」。
第六個踩雷是「忘記處理 useSearchParams 的 Suspense 邊界」。在 Next.js 15 的靜態渲染階段,Client Component 使用 useSearchParams 會被要求包在 <Suspense> 內,否則 build 會警告、部分頁面退回全動態渲染。修法:把清單元件包進 Suspense 並給 fallback,今天的頁面就是這樣寫的。
效能與實務提醒
這一頁的初始資料由 Server Component 直接帶進 HTML,client 端篩選不會再多打任何 API,對 50 筆以內的清單是最快的做法。若未來服務數量成長到數百筆,建議把 ServicesList 改成用 TanStack Query 搭配 keepPreviousData 取得分頁資料,並把篩選條件一起送給後端;元件結構不必大改,只換資料來源。
另一個實務提醒是「篩選條件數量」。我們刻意只留三個主條件,因為每多一個條件,使用者就要多花一秒判斷「這個欄位要不要動」。真的需要更多維度時,正確的做法是收進「更多篩選」的摺疊面板,而不是一路往下堆。
手機版的篩選也要重新安排,而不是把桌機的側欄縮小。今天的 lg:grid-cols-[280px_1fr] 在桌機是左側面板、手機自動變成單欄(篩選疊在清單上方)。對小螢幕更友善的做法是把手機版的篩選收進可摺疊的 details 元素,點開才展開,這一步留到 Day 42 的響應式總檢處理。
最後提醒 mock 模式的可見性。只要 NEXT_PUBLIC_API_MODE=mock,Day 32 的「Mock 模式」浮動標籤就會出現,開發者一眼就知道目前資料來自前端 fixture、不會打到後端。這對示範與測試都很重要,也避免了「誤以為按鈕真的建立了預約」的誤會。
小結
今天把 /services 從佔位頁變成可用的服務清單與篩選介面。重點回顧:網址驅動的篩選狀態讓結果可分享、可書籤;Server Component 負責首屏資料,Client Component 負責互動;useDeferredValue 與 useTransition 讓篩選保持流暢;applyFilter 抽成純函式以便測試;載入、空白、錯誤三種狀態都有明確處理。資料層也從「不帶參數」擴充成「接受 filter」,為 Day 36 的正式 API 串接預留位置。
結語
清單與篩選是客戶進入預約系統的第一個畫面,它做得好不好,直接決定客戶願不願意繼續往下走。今天我們用 Day 32 的設計系統元件把這頁組起來,沒有新增任何視覺規則,只增加資料層的篩選能力與狀態處理。明天 Day 34 我們會接著做預約流程與表單:把 /bookings/new 從佔位頁變成「選擇時段、填寫資料、確認預約」的完整流程,並沿用今天 ServiceCard 上「查看時段」的連結。讀完這篇你應該能回答:為什麼篩選要用網址 state?Server Component 與 Client Component 的責任怎麼分?useDeferredValue 適合用在哪裡?空狀態要怎麼設計才友善?
延伸資源
- Next.js 15
useSearchParams:https://nextjs.org/docs/app/api-reference/functions/use-search-params,網址狀態管理的官方說明。 - React
useTransition:https://react.dev/reference/react/useTransition,非同步過渡狀態的標準用法。 - React
useDeferredValue:https://react.dev/reference/react/useDeferredValue,延遲更新值的官方說明。 - TanStack Query 5.x:
https://tanstack.com/query/latest,伺服器狀態管理(Day 14 介紹)。 - Tailwind CSS 4.x
line-clamp:https://tailwindcss.com/docs/line-clamp,文字截斷的 utility。 - Web 系列 Day 35 的預約系統資料模型:
https://blog.hao-code.com/2025/07/web-day-35.html,後端欄位對照來源。
留言
張貼留言