電子商務
規則
- 購物車狀態:使用 Zustand store 搭配
localStorage 持久化 — 在頁面重整與分頁關閉後仍能保留
- 產品規格:將規格建模為獨立的 SKU,並共用同一個上層產品 — 每個規格擁有自己的價格、庫存、圖片
- 結帳:Stripe Checkout Sessions(伺服器端)— 絕不從客戶端傳送價格
- 庫存:在建立結帳工作階段前,在伺服器端檢查庫存 — 使用資料庫交易防止競態條件
- 訂單生命週期:
pending → paid → shipped → delivered — 透過 Webhook 事件更新,而非客戶端操作
- 產品列表:伺服器端過濾/排序/分頁 — 使用 URL 搜尋參數建立可分享的篩選檢視
- 價格:以分(整數)儲存 — 使用
Intl.NumberFormat 與貨幣代碼顯示
- 購物車操作:新增、移除、更新數量、清空 — 在每次購物車操作時驗證庫存
模式
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface CartItem { productId: string; variantId: string; quantity: number; price: number; }
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (variantId: string) => void;
updateQuantity: (variantId: string, quantity: number) => void;
clear: () => void;
total: () => number;
}
export const useCart = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) => set((state) => {
const existing = state.items.find((i) => i.variantId === item.variantId);
if (existing) {
return { items: state.items.map((i) =>
i.variantId === item.variantId ? { ...i, quantity: i.quantity + item.quantity } : i
)};
}
return { items: [...state.items, item] };
}),
removeItem: (variantId) => set((s) => ({ items: s.items.filter((i) => i.variantId !== variantId) })),
updateQuantity: (variantId, quantity) => set((s) => ({
items: quantity <= 0
? s.items.filter((i) => i.variantId !== variantId)
: s.items.map((i) => (i.variantId === variantId ? { ...i, quantity } : i)),
})),
clear: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}),
{ name: "cart-storage" }
)
);
避免
- 從客戶端傳送價格以建立結帳 — 應始終從你的資料庫在伺服器端解析價格
- 僅將購物車儲存在 React 狀態中 — 使用持久化的 Zustand 或 localStorage,讓購物車在重整後仍可保留
- 在「加入購物車」時扣除庫存 — 僅在結帳時保留庫存,並在工作階段過期時釋放
- 從客戶端更新訂單狀態 — 使用 Stripe Webhook 處理
checkout.session.completed
- 使用浮點數處理金錢 — 以整數(分)儲存,僅在顯示時進行格式化