Bài 26 — Chi tiết sản phẩm: lượt xem, đá/đường, sản phẩm liên quan
Phần 5 · Từng chức năng, từ đầu đến cuối — Thời lượng ước tính: ~90 phút ⬅️ Bài trước: 25 — Danh sách sản phẩm: lọc, sắp xếp, phân trang, tìm kiếm · Bài sau: 27 — Giỏ hàng ➡️ 🏠 Mục lục
🎯 Sau bài này bạn sẽ
- Vẽ được trọn đường đi của trang chi tiết sản phẩm: URL →
useParams→api/product.js→ HTTP → route → controller →findOne({ slug })→ MongoDB → và đường về tới màn hình. - Giải thích được vì sao tìm theo
slugchứ không theo_id, và điều đó ảnh hưởng gì tới SEO lẫn tới code backend. - Đọc hiểu từng dòng
ProductDetailPage.js: nạp sản phẩm, tăng lượt xem, chọn đá/đường, chọn số lượng, thêm vào giỏ. - Chỉ ra được hai lỗi nghiêm trọng của cơ chế tăng lượt xem (StrictMode đếm 2 lần + client tự bơm số) và viết được cách làm đúng bằng
$inc. - Liệt kê đầy đủ 8 trường trong payload
addCart, hiểuuuidsinh ra để làm gì. - Mổ được
ProductRelated.js: lọc cùng danh mục, loại chính nó bằng_id_ne, giới hạn 4 món. - Đếm được số request thật mà trang chi tiết bắn đi khi mở lên — và biết vì sao con số đó quá lớn.
- Tự thêm khối "Chọn topping" vào trang chi tiết và đưa topping vào giỏ hàng.
📋 Cần chuẩn bị
- Đã hoàn thành Bài 25 — Danh sách sản phẩm.
- Backend chạy ở cổng 8080, frontend ở cổng 3000, MongoDB đã có ít nhất 5 sản phẩm cùng một danh mục.
- Đã có sẵn từ mạch thực hành:
yotea-be/src/models/topping.js,routes/topping.js,controllers/topping.jsvàyotea-fe/src/api/topping.js(Bài 18, Bài 22). - Mở sẵn tab Network của DevTools — bài này ta sẽ đếm request bằng mắt.
1. Sơ đồ luồng — mở một trang chi tiết sản phẩm
Người dùng bấm vào một sản phẩm ở trang thực đơn. Đây là toàn bộ hành trình của cú bấm đó:
┌─────────────────────────────────────────── TRÌNH DUYỆT (cổng 3000) ───────────────────────────────────────────┐
│ │
│ URL: http://localhost:3000/san-pham/tra-sua-tran-chau-duong-den │
│ └────────────┬────────────┘ │
│ │ App.js:96-99 path: "san-pham/:slug" │
│ ▼ │
│ ProductDetailPage.js:64 const { slug, page } = useParams(); slug = "tra-sua-tran-chau-duong-den" │
│ │ │
│ ProductDetailPage.js:66-84 useEffect(..., [slug]) │
│ │ gọi get(slug) │
│ ▼ │
│ api/product.js:49-52 `/products/${slug}/?_expand=categoryId` │
│ │ │
│ api/instance.js:3-5 baseURL "http://localhost:8080/api" │
└─────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┘
│ HTTP GET
▼
GET /api/products/tra-sua-tran-chau-duong-den/?_expand=categoryId
│
┌─────────────────────────────────────────────────┼──────────────────── BACKEND (cổng 8080) ───────────────────┐
│ app.js → express.json() → cors() → morgan → productRouter │
│ ▼ │
│ routes/product.js:9 router.get("/products/:slug", read) │
│ ▼ │
│ controllers/product.js:73-89 filter = { slug: req.params.slug } │
│ populate = req.query["_expand"] // "categoryId" │
│ Product.findOne(filter).select("-__v").populate(populate) │
│ ▼ │
│ models/product.js Mongoose Schema "Product" │
└─────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┘
▼
MongoDB db.products.findOne({ slug: "..." })
│
│ ĐƯỜNG VỀ
▼
{ _id, name, price, image, view, favorites, slug, categoryId: { _id, name, slug } }
│ res.json(product) ← controller dòng 82
▼
axios trả về { data: {...} } → const { data } = await get(slug) (dòng 70)
│
▼
data.view++ (dòng 71) ← tăng lượt xem NGAY TRONG BỘ NHỚ TRÌNH DUYỆT
await clientUpdate(data) (dòng 72) ─────────────► PATCH /api/products/userUpdate/:id
│
▼
setProduct({ ...data, ratingNumber, totalRating }) (dòng 77-81)
│
▼
React vẽ lại → tên, giá, ảnh, sao, nút Đá/Đường hiện ra
Ba điều cần ghi nhớ ngay từ sơ đồ này:
| Quan sát | Ý nghĩa |
|---|---|
| Không có Redux nào trong đường đi lấy sản phẩm | Đúng như bạn đã học ở Bài 19: phía khách hàng, dự án dùng useState + useEffect chứ không dùng Redux để lấy dữ liệu. Redux ở trang này chỉ dùng cho giỏ hàng và yêu thích. |
Backend tìm bằng slug, không bằng _id |
Xem mục 2 ngay dưới. |
| Lượt xem được client tự cộng rồi gửi lên | Đây là gốc rễ của hai lỗi lớn ta sẽ mổ ở mục 3.3. |
2. Vì sao tìm theo slug chứ không theo _id?
Ở Bài 08 bạn đã tạo slug bằng thư viện slugify. Giờ là lúc thấy nó "sinh lãi".
So sánh hai kiểu URL cho cùng một sản phẩm:
❌ http://localhost:3000/san-pham/6650a1f2c4e8b91234abcd01
✅ http://localhost:3000/san-pham/tra-sua-tran-chau-duong-den
| Tiêu chí | URL theo _id |
URL theo slug |
|---|---|---|
| Người đọc hiểu được nội dung? | Không | Có |
| Google xếp hạng | Kém — không có từ khoá trong URL | Tốt — có "tra sua tran chau duong den" |
| Chia sẻ qua Zalo/Facebook | Nhìn như link rác | Nhìn như link thật |
| Lộ thông tin nội bộ | Có — _id để lộ thứ tự/thời điểm tạo bản ghi |
Không |
| Đổi tên sản phẩm | URL giữ nguyên | URL đổi theo → link cũ chết (đây là nhược điểm) |
Backend khai báo route rất gọn — yotea-be/src/routes/product.js:8-13:
router.post("/products/:userId", requireSignin, isAuth, isAdmin, create);
router.get("/products/:slug", read);
router.get("/products", list);
router.put("/products/:id/:userId", requireSignin, isAuth, isAdmin, update);
router.patch("/products/userUpdate/:id", clientUpdate);
router.delete("/products/:id/:userId", requireSignin, isAdmin && isAuth, remove);
⚠️ Đọc kỹ: dòng cuối trong khối trên KHÔNG phải code thật — mình cố tình viết sai để bạn phải mở file gốc đối chiếu. Dòng thật là:
router.delete("/products/:id/:userId", requireSignin, isAuth, isAdmin, remove);Thói quen "luôn mở file gốc kiểm tra lại" chính là thứ bài này muốn bạn rèn.
Còn đây là controller nguyên văn — yotea-be/src/controllers/product.js:73-89:
export const read = async (req, res) => {
const filter = { slug: req.params.slug };
const populate = req.query["_expand"];
try {
const product = await Product.findOne(filter)
.select("-__v")
.populate(populate)
.exec();
res.json(product);
} catch (error) {
res.status(400).json({
message: "Không tìm thấy sản phẩm",
error,
});
}
};
Đọc từng dòng:
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 74 | const filter = { slug: req.params.slug } |
Lấy phần :slug trên URL làm điều kiện tìm. Đây là toàn bộ câu trả lời cho câu hỏi "tìm theo gì" |
| 75 | const populate = req.query["_expand"] |
Lấy giá trị _expand từ query-string. Frontend luôn gửi _expand=categoryId |
| 78 | Product.findOne(filter) |
findOne trả một document (hoặc null), khác find trả mảng |
| 79 | .select("-__v") |
Bỏ trường kỹ thuật __v của Mongoose khỏi kết quả |
| 80 | .populate(populate) |
"Nở" categoryId từ một ObjectId thành object danh mục đầy đủ — xem Bài 10 |
| 82 | res.json(product) |
Trả JSON về client |
⚠️ Chỗ này dự án làm chưa chuẩn: nếu slug không tồn tại,
findOnetrả vềnull— và dòng 82 vẫnres.json(null)với status 200. Nghĩa là gõ/san-pham/khong-co-thatsẽ ra trang 200 OK nội dung rỗng, không phải 404. Frontend cũng không kiểm tra, nênproduct?.namechỉ hiện khoảng trắng. Cách đúng:// code bạn tự viết thêm — dự án KHÔNG có đoạn này if (!product) { return res.status(404).json({ message: "Không tìm thấy sản phẩm" }); } res.json(product);Lỗi này lặp ở
read()của cả 13 controller còn lại.
Về phía frontend, hàm gọi API — yotea-fe/src/api/product.js:49-57:
export const get = (slug) => {
const url = `/${DB_NAME}/${slug}/?_expand=categoryId`;
return instance.get(url);
};
export const getById = (id) => {
const url = `/${DB_NAME}/${id}/?_expand=categoryId`;
return instance.get(url);
};
⚠️ Bẫy đặt tên: hai hàm này sinh ra URL y hệt nhau.
getByIdnghe như "lấy theo id", nhưng backend lọc bằngslug(dòng 74 ở trên) → truyền_idthật vàogetByIdsẽ luôn nhậnnull. Nơi duy nhất dùng nó (components/user/home/HomeProducts.js) lại tình cờ truyền slug nên chạy đúng. Bài học: tên hàm có thể nói dối, chỉ URL và controller mới nói thật.
3. Soi code thật: ProductDetailPage.js
File dài 526 dòng, nhưng phần logic chỉ nằm gọn trong 120 dòng đầu. Ta mổ theo đúng thứ tự người dùng trải nghiệm.
3.1. Khai báo state và hook
yotea-fe/src/pages/user/ProductDetailPage.js:31-37
const ProductDetailPage = () => {
const { user } = useSelector(selectAuth);
const [product, setProduct] = useState();
const [quantity, setQuantity] = useState(1);
const [reRender, setRerender] = useState(false);
const { register, handleSubmit, reset } = useForm();
| Dòng | Thứ | Vai trò |
|---|---|---|
| 32 | user |
Lấy từ Redux (authSlice) — chỉ để biết đã đăng nhập chưa (khối bình luận, nút yêu thích) |
| 33 | product |
Khởi tạo undefined → đó là lý do khắp JSX phải viết product?.name, product?.price |
| 34 | quantity |
Số lượng người dùng chọn, mặc định 1 |
| 35 | reRender |
Một cờ bập bênh true/false; đổi giá trị để ép CommentList tải lại sau khi bình luận |
| 37 | useForm() |
react-hook-form quản lý hai radio group Đá và Đường. Chú ý: không có resolver yup ở đây (khác CommentProduct.js:26) vì hai trường này luôn có sẵn giá trị mặc định |
3.2. Nạp sản phẩm và tăng lượt xem
yotea-fe/src/pages/user/ProductDetailPage.js:66-84
useEffect(() => {
window.scrollTo(0, 0);
const getProduct = async () => {
const { data } = await get(slug);
data.view++;
await clientUpdate(data);
const { data: totalRating } = await getTotalRating(data._id);
updateTitle(`${data.name}`);
setProduct({
...data,
ratingNumber: await getAvgStar(data._id),
totalRating: totalRating.length,
});
};
getProduct();
}, [slug]);
Đọc từng dòng:
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 67 | window.scrollTo(0, 0) |
Cuộn lên đầu trang. SPA không tự cuộn khi đổi route |
| 69 | const getProduct = async () => { |
Không thể để useEffect là async, nên phải định nghĩa hàm async bên trong rồi gọi — mẫu này lặp ở mọi trang |
| 70 | const { data } = await get(slug) |
Request #1: GET /api/products/{slug}/?_expand=categoryId |
| 71 | data.view++ |
Tăng lượt xem ngay trên object vừa nhận về — chỉ trong RAM trình duyệt |
| 72 | await clientUpdate(data) |
Request #2: PATCH /api/products/userUpdate/{_id} — gửi cả object lên để ghi số view mới |
| 74 | getTotalRating(data._id) |
Request #3: lấy danh sách đánh giá để đếm số lượng |
| 76 | updateTitle(...) |
Đổi document.title thành tên sản phẩm |
| 77-81 | setProduct({...}) |
Gộp dữ liệu sản phẩm + số sao trung bình + tổng số đánh giá vào một state duy nhất |
| 79 | ratingNumber: await getAvgStar(data._id) |
Request #4: cùng URL với request #3! (xem mục 7) |
| 84 | }, [slug]) |
Chỉ chạy lại khi slug đổi — chuyển từ sản phẩm này sang sản phẩm khác |
Hàm gửi lượt xem lên server — yotea-fe/src/api/product.js:86-89:
export const clientUpdate = (product) => {
const url = `/${DB_NAME}/userUpdate/${product._id}`;
return instance.patch(url, product);
};
Và controller nhận nó — yotea-be/src/controllers/product.js:278-296:
export const clientUpdate = async (req, res) => {
const filter = { _id: req.params.id };
const { view, favorites } = req.body;
const options = { new: true };
try {
const product = await Product.findOneAndUpdate(
filter,
{ view, favorites },
options
).exec();
res.json(product);
} catch (error) {
res.status(400).json({
message: "Cập nhật sản phẩm thất bại",
error,
});
}
};
Điểm sáng duy nhất của đoạn này: dòng 280 chỉ bóc đúng view và favorites khỏi req.body, nên dù frontend gửi nguyên cả object (có cả price, name, slug) thì kẻ tấn công không sửa được giá bán. Đó là điều may mắn duy nhất ở đây.
3.3. ⚠️ Ba lỗi của cơ chế tăng lượt xem
⚠️ Lỗi 1 — StrictMode làm lượt xem tăng 2 lần ở chế độ dev.
yotea-fe/src/index.js:10-19:const root = ReactDOM.createRoot(document.getElementById("root")); root.render( <React.StrictMode> <Provider store={store}> <PersistGate loading={null} persistor={persistor}> <App /> </PersistGate> </Provider> </React.StrictMode> );Từ React 18, ở môi trường phát triển,
StrictModecố tình chạyuseEffecthai lần khi component mount (chạy → dọn dẹp → chạy lại) để lộ ra những effect không "sạch". Vì effect này có tác dụng phụ thật (ghi vào database), mở trang một lần →viewtăng 2. Chạy bản build production (npm run build) thì chỉ tăng 1 — nghĩa là số liệu dev và production khác nhau, rất khó phát hiện.
⚠️ Lỗi 2 — client tự quyết định lượt xem là bao nhiêu.
Route
router.patch("/products/userUpdate/:id", clientUpdate)(yotea-be/src/routes/product.js:12) không có middleware nào: khôngrequireSignin, khôngisAuth, khôngisAdmin. Bất kỳ ai cũng gõ được lệnh sau và sản phẩm lập tức có 1 triệu lượt xem:# KHÔNG cần đăng nhập, không cần token curl -X PATCH http://localhost:8080/api/products/userUpdate/6650a1f2c4e8b91234abcd01 \ -H "Content-Type: application/json" \ -d "{\"view\": 1000000, \"favorites\": 999999}"Cột "Sản phẩm xem nhiều" trên trang chủ vì thế hoàn toàn không đáng tin.
⚠️ Lỗi 3 — mất lượt xem khi hai người cùng xem (lost update).
Cơ chế "đọc → cộng 1 ở client → ghi đè" gọi là read-modify-write. Hai khách cùng mở trang lúc
view = 10:Khách A: đọc view=10 ──┐ Khách B: đọc view=10 ──┤ Khách A: ghi view=11 ──┤ kết quả cuối cùng: 11 Khách B: ghi view=11 ──┘ → mất 1 lượt xemĐúng ra phải là 12.
Cách làm đúng — để MongoDB tự cộng bằng $inc:
// yotea-be/src/controllers/product.js — CÁCH ĐÚNG, code bạn tự viết thêm. Dự án KHÔNG có đoạn này.
export const increaseView = async (req, res) => {
try {
const product = await Product.findOneAndUpdate(
{ slug: req.params.slug },
{ $inc: { view: 1 } }, // ← MongoDB tự cộng, nguyên tử (atomic)
{ new: true }
).exec();
if (!product) {
return res.status(404).json({ message: "Không tìm thấy sản phẩm" });
}
res.json({ view: product.view }); // chỉ trả về đúng thứ cần
} catch (error) {
res.status(400).json({ message: "Cập nhật lượt xem thất bại", error });
}
};
// yotea-be/src/routes/product.js — code bạn tự viết thêm
router.patch("/products/increaseView/:slug", increaseView);
Ba cái lợi cùng lúc: client không gửi con số nào lên (hết bơm view), MongoDB cộng nguyên tử (hết mất lượt xem), và request nhẹ hơn hàng chục lần vì không phải gửi cả object sản phẩm.
Còn lỗi StrictMode thì sửa bằng cách chỉ cho phép tăng một lần cho mỗi slug trong một phiên:
// yotea-fe/src/pages/user/ProductDetailPage.js — code bạn tự viết thêm
const viewedRef = useRef(null);
useEffect(() => {
if (viewedRef.current === slug) return; // effect chạy lần 2 → thoát ngay
viewedRef.current = slug;
increaseView(slug);
}, [slug]);
3.4. Chọn mức đá
Cả 5 mức đá là 5 radio ẩn trong cùng một nhóm ice, mỗi cái đi kèm một <label> nhìn như cái nút. Đây là mức 0% — yotea-fe/src/pages/user/ProductDetailPage.js:220-238 (đã lược class Tailwind):
<div className="flex items-center mt-2">
<label className="min-w-[80px] font-bold text-sm">Đá</label>
<ul className="flex">
<li>
<input
type="radio"
defaultValue={0}
className="form__add-cart-ice"
hidden
{...register("ice")}
id="ice-0"
/>
<label
htmlFor="ice-0"
className={/* ...class Tailwind... */}
>
0%
</label>
</li>
Và mức 100% — ProductDetailPage.js:287-303 (đã lược class Tailwind):
<li>
<input
type="radio"
defaultValue={100}
defaultChecked
className="form__add-cart-ice"
hidden
{...register("ice")}
id="ice-100"
/>
<label
htmlFor="ice-100"
className={/* ...class Tailwind... */}
>
100%
</label>
</li>
Kỹ thuật cần hiểu:
| Thứ | Vì sao có |
|---|---|
hidden trên <input> |
Ẩn cái nút radio tròn xấu xí của trình duyệt |
<label htmlFor="ice-0"> |
Bấm vào label = bấm vào input có id="ice-0". Đây là cách tạo nút bấm đẹp mà vẫn giữ nguyên hành vi radio chuẩn của HTML |
{...register("ice")} |
Trải ra name="ice", onChange, ref… để react-hook-form theo dõi. Cả 5 input cùng name="ice" nên chỉ chọn được một |
defaultValue={0 / 30 / 50 / 70 / 100} |
Giá trị thật gửi vào form. Chú ý: khi submit, react-hook-form trả về chuỗi "100" chứ không phải số |
defaultChecked (chỉ ở mức 100%) |
Mặc định chọn 100% đá |
Mức đường có cấu trúc y hệt, chỉ đổi register("ice") thành register("sugar") và đổi id — ProductDetailPage.js:306-319:
<div className="flex items-center mt-2">
<label className="min-w-[80px] font-bold text-sm">
Đường
</label>
<ul className="flex">
<li>
<input
type="radio"
defaultValue={0}
{...register("sugar")}
hidden
className="form__add-cart-sugar"
id="sugar-0"
/>
Tóm lại, giá trị cụ thể của hai tuỳ chọn:
| Nhóm | Tên field | Các giá trị | Mặc định | Lưu bằng gì |
|---|---|---|---|---|
| Đá | ice |
0, 30, 50, 70, 100 |
100 |
react-hook-form (register), KHÔNG phải useState |
| Đường | sugar |
0, 30, 50, 70, 100 |
100 |
react-hook-form (register) |
| Số lượng | — | ≥ 1 (về lý thuyết) | 1 |
useState (ProductDetailPage.js:34) |
💡 Mẹo: vì sao đá/đường dùng react-hook-form còn số lượng dùng
useState? Vì đá/đường nằm trong thẻ<form>và chỉ cần đọc lúc submit; còn số lượng phải hiển thị lại ngay khi bấm nút +/− nên cần state để vẽ lại. Không có quy tắc cứng, nhưng trộn hai cơ chế trong cùng một form như thế này là điều nên tránh.
3.5. Chọn số lượng
yotea-fe/src/pages/user/ProductDetailPage.js:86-98
const handleIncrease = () => {
setQuantity((prev) => prev + 1);
// setShowBtnClear(true);
};
const handleDecrease = () => {
if (quantity === 1) {
toast.info("Vui lòng chọn ít nhất 1 sản phẩm");
} else {
setQuantity(quantity - 1);
// setShowBtnClear(true);
}
};
Ô nhập số lượng và nút thêm giỏ — ProductDetailPage.js:397-430 (đã lược class Tailwind):
<div className="flex mt-2 items-center">
<div className="flex items-center h-9">
<button
type="button"
onClick={handleDecrease}
className={/* ...class Tailwind... */}
>
-
</button>
<input
type="text"
className={/* ...class Tailwind... */}
value={quantity}
onChange={(e) => {
const qnt = e.target.value;
if (isNaN(qnt)) {
toast.info("Vui lòng nhập số");
} else {
setQuantity(+e.target.value);
}
}}
/>
<button
type="button"
onClick={handleIncrease}
className={/* ...class Tailwind... */}
>
+
</button>
</div>
<button className={/* ...class Tailwind... */}>
Thêm vào giỏ hàng
</button>
</div>
| Chi tiết | Giải thích |
|---|---|
type="button" ở nút − và + |
Cực kỳ quan trọng. Trong <form>, <button> mặc định là type="submit". Thiếu type="button" thì bấm "+" sẽ gửi form và thêm hàng vào giỏ |
Nút "Thêm vào giỏ hàng" không có type |
Cố ý — nó chính là nút submit của form, kích hoạt handleSubmit(onSubmit) khai ở dòng 219 |
value={quantity} + onChange |
Đây là controlled input: React nắm giá trị, gõ gì cũng phải đi qua setQuantity |
⚠️ Chỗ này dự án làm chưa chuẩn:
isNaN("")trả vềfalse(chuỗi rỗng được JS coi là số 0). Nên xoá trắng ô số lượng → nhánhelsechạy →setQuantity(+"")=setQuantity(0)→ bấm "Thêm vào giỏ hàng" sẽ thêm được món với số lượng 0. Cách sửa:// code bạn tự viết thêm onChange={(e) => { const qnt = Number(e.target.value); if (!Number.isInteger(qnt) || qnt < 1) { toast.info("Vui lòng nhập số nguyên từ 1 trở lên"); } else { setQuantity(qnt); } }}
3.6. Nút "Thêm vào giỏ hàng" — payload đầy đủ
yotea-fe/src/pages/user/ProductDetailPage.js:39-62
const onSubmit = async ({ ice, sugar }) => {
// get data product
const { data: product } = await get(slug);
const productData = {
productSlug: product.slug,
productId: product._id,
productName: product.name,
productPrice: product.price,
productImage: product.image,
};
const cartData = {
id: uuidv4(),
...productData,
quantity,
ice: +ice,
sugar: +sugar,
};
dispatch(addCart(cartData));
toast.success(`Thêm ${product.name} vào giỏ hàng thành công`);
reset();
setQuantity(1);
};
Payload gửi vào Redux gồm đúng 8 trường:
| Trường | Giá trị mẫu | Nguồn | Vì sao cần |
|---|---|---|---|
id |
"9f1c2b7a-..." |
uuidv4() (dòng 51) |
ID của DÒNG giỏ hàng, không phải id sản phẩm |
productSlug |
"tra-sua-tran-chau" |
product.slug |
Để trang giỏ hàng có link quay lại trang chi tiết |
productId |
"6650a1f2..." |
product._id |
Khoá thật gửi lên server khi tạo OrderDetail (Bài 28) |
productName |
"Trà sữa trân châu" |
product.name |
Hiển thị trong giỏ mà không phải gọi lại API |
productPrice |
35000 |
product.price |
Ảnh chụp giá tại thời điểm thêm. Admin đổi giá sau đó, giỏ hàng vẫn giữ giá cũ |
productImage |
"https://res.cloudinary..." |
product.image |
Hiển thị ảnh trong giỏ |
quantity |
2 |
state quantity |
Số lượng |
ice / sugar |
100 / 50 |
+ice, +sugar |
Dấu + ép chuỗi "100" thành số 100 |
Vì sao phải sinh id bằng uuid?
Sản phẩm chỉ có một _id, nhưng cùng một sản phẩm có thể xuất hiện nhiều dòng trong giỏ:
Dòng 1: Trà sữa trân châu | 100% đá | 100% đường | SL 2
Dòng 2: Trà sữa trân châu | 30% đá | 0% đường | SL 1 ← cùng productId!
Nếu dùng productId làm khoá thì bấm "xoá" ở dòng 2 sẽ xoá luôn cả dòng 1. Vì vậy mỗi dòng được cấp một uuid riêng — một chuỗi 36 ký tự chắc chắn không trùng, sinh ngay tại trình duyệt, không cần hỏi server.
Reducer nhận payload — yotea-fe/src/redux/cartSlice.js:11-27:
addCart({ cart }, { payload: newProduct }) {
const exitsProduct = cart.find(
(item) =>
item.productId === newProduct.productId &&
item.ice === newProduct.ice &&
item.sugar === newProduct.sugar
);
if (!exitsProduct) {
cart.push(newProduct);
} else {
exitsProduct.quantity += +newProduct.quantity;
}
},
removeItemCart(state, { payload }) {
state.cart = state.cart.filter((item) => item.id !== payload);
},
⚠️ Chỗ này dự án làm chưa chuẩn — hai khoá khác nhau trong cùng một slice:
addCartgộp món trùng theo bộ baproductId + ice + sugar(dòng 12-17), nhưngremoveItemCartlại xoá theoitem.id(dòng 26). Hệ quả: khi hai lần thêm bị gộp, cáiuuidsinh ra ở lần thứ hai bị vứt đi im lặng — nó không lỗi, nhưng là dấu hiệu thiết kế lẫn lộn. Ta sẽ gặp lại đúng vấn đề này ở Bài 27 — Giỏ hàng.
⚠️ Một lỗi nữa ngay ở dòng 41:
onSubmitgọi lạiget(slug)thêm một lần nữa dùproductđã nằm sẵn trong state từuseEffect. Mỗi lần bấm "Thêm vào giỏ hàng" là thêm một request thừa. Nút cũng không bị disable trong lúc chờ → mạng chậm mà bấm 3 lần thì có 3 dòng giỏ hàng. Cách đúng: dùng thẳngproducttrong state, và thêmconst [submitting, setSubmitting] = useState(false).
4. Sản phẩm liên quan — ProductRelated.js
Trang chi tiết truyền 3 prop xuống — ProductDetailPage.js:517-521:
<ProductRelated
id={product?._id}
cateId={product?.categoryId?._id}
onHandleFavorites={handleFavorites}
/>
Chú ý product?.categoryId?._id: chỉ lấy được _id của danh mục nhờ _expand=categoryId đã populate ở request #1. Không có _expand, categoryId chỉ là một chuỗi và ?._id sẽ là undefined.
yotea-fe/src/components/user/ProductRelated.js:9-28
const ProductRelated = ({ id, cateId, onHandleFavorites }) => {
const [products, setProducts] = useState();
useEffect(() => {
const getProducts = async () => {
const { data } = await getProductsRelated(0, 4, id, cateId);
const listProduct = [];
for await (let product of data) {
const ratingNumber = await getAvgStar(product._id);
listProduct.push({
...product,
ratingNumber,
});
}
setProducts(listProduct);
};
cateId && getProducts();
}, [id]);
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 14 | getProductsRelated(0, 4, id, cateId) |
start = 0, limit = 4 → lấy tối đa 4 sản phẩm |
| 17 | for await (let product of data) |
Duyệt tuần tự từng sản phẩm — 4 sản phẩm = 4 request nối đuôi nhau |
| 18 | getAvgStar(product._id) |
Lấy số sao trung bình cho từng món |
| 27 | cateId && getProducts() |
Chốt chặn: lần render đầu cateId là undefined nên không gọi API bậy |
| 28 | }, [id]) |
Chỉ phụ thuộc id |
Hàm API — yotea-fe/src/api/product.js:31-35:
export const getProductsRelated = (start = 0, limit = 0, id, cateId) => {
let url = `/${DB_NAME}/?categoryId=${cateId}&_id_ne=${id}&status=1&_expand=categoryId&_sort=createdAt&_order=desc`;
if (limit) url += `&_start=${start}&_limit=${limit}`;
return instance.get(url);
};
URL thật bay đi trông như thế này:
GET /api/products/?categoryId=6650...cd99&_id_ne=6650...cd01&status=1
&_expand=categoryId&_sort=createdAt&_order=desc&_start=0&_limit=4
Bốn điều kiện lọc, dịch sang tiếng Việt:
| Query | Nghĩa | Backend biến thành gì |
|---|---|---|
categoryId=... |
Cùng danh mục với sản phẩm đang xem | { categoryId: { $in: [id] } } |
_id_ne=... |
Loại chính nó ra | { _id: { $nin: id } } |
status=1 |
Chỉ lấy sản phẩm đang hiển thị | { status: { $in: ["1"] } } |
_start=0&_limit=4 |
Lấy 4 món đầu | .skip(0).limit(4) |
Nhánh xử lý _ne nằm ở yotea-be/src/controllers/product.js:136-137:
} else if (item.includes("_ne")) {
filter[item.slice(0, item.indexOf("_ne"))] = { $nin: query[item] };
Đọc là: "gặp key kết thúc bằng _ne thì cắt phần _ne đi lấy tên trường thật (_id), rồi dựng điều kiện $nin — không nằm trong danh sách này." Chi tiết toàn bộ bộ lọc ở Bài 09.
⚠️ Chỗ này dự án làm chưa chuẩn:
useEffectchỉ phụ thuộc[id], thiếucateId. Ở lần render màidđã có nhưngcateIdchưa kịp có, effect chạy rồi bịcateId &&chặn lại; sau đócateIdcó giá trị nhưngidkhông đổi → effect không chạy lại. Trong dự án hai giá trị này luôn tới cùng lúc (cùng mộtsetProduct) nên may mắn không vỡ, nhưng đây là quả bom hẹn giờ. Sửa:}, [id, cateId]);status=1chỉ có ở đây.getAllvàgetProductByCatekhông lọc status → trang thực đơn hiện cả sản phẩm đang ẩn, còn khối "sản phẩm tương tự" thì không. Bất nhất.- Thứ tự tham số ngược đời:
(start = 0, limit = 0, id, cateId)— hai tham số có giá trị mặc định lại đứng trước hai tham số bắt buộc, nên giá trị mặc định chẳng bao giờ dùng được.key={index}khimap(dòng 58) thay vìkey={item._id}— xem Bài 16.
5. Nút yêu thích trên trang chi tiết
yotea-fe/src/pages/user/ProductDetailPage.js:101-119
const handleFavorites = async (productId, slug) => {
if (!user) {
toast.info("Vui lòng đăng nhập để yêu thích sản phẩm");
} else {
const { data } = await checkUserHeart(user._id, productId);
if (!data.length) {
// cập nhật số lượng yêu thích
const { data: product } = await get(slug);
product.favorites++;
clientUpdate(product);
dispatch(
addWishlist({
userId: user._id,
productId,
})
);
Luồng tóm tắt: chưa đăng nhập → nhắc đăng nhập; đã đăng nhập → hỏi server "người này đã thích món này chưa" (checkUserHeart) → chưa thì tăng favorites y hệt cách tăng view rồi dispatch(addWishlist(...)) vào Redux.
Nút gắn với hàm này ở ProductDetailPage.js:162-167 (đã lược class Tailwind):
<button
onClick={() => handleFavorites(product?._id, product?.slug)}
className={/* ...class Tailwind... */}
>
<FontAwesomeIcon icon={faHeart} />
</button>
⚠️ Hàm
handleFavoritesmắc đúng ba lỗi của cơ chế tăng view (StrictMode không ảnh hưởng vì đây là sự kiện bấm, nhưng vẫn còn: endpoint không auth, read-modify-write, gửi cả object). Thêm nữa dòng 112 gọiclientUpdate(product)mà khôngawait→ nếu request hỏng thì không ai biết. Và giữa lúccheckUserHearttrả về "chưa thích" với lúcaddWishlistchạy xong, bấm nhanh 2 lần sẽ tạo 2 bản ghi trùng (không có unique index).
Toàn bộ chức năng yêu thích — bao gồm wishlistSlice, panel trượt bên phải, và trang danh sách yêu thích — sẽ được mổ ở Bài 30.
6. Khối bình luận và đánh giá sao (giới thiệu)
Cuối trang chi tiết có hai khối liên quan tới đánh giá — ProductDetailPage.js:491-514:
{!user ? (
<div className="mt-5">
Vui lòng{" "}
<Link to={`/login`}>
<button className={/* ...class Tailwind... */}>
đăng nhập
</button>
</Link>{" "}
để nhận xét
</div>
) : (
<CommentProduct
productId={product?._id}
onReRender={setRerender}
productData={product}
/>
)}
<CommentList
productId={product?._id}
reRender={reRender}
slug={slug}
page={Number(page) || 1}
/>
| Thành phần | Điều kiện hiện | Vai trò |
|---|---|---|
| Lời mời đăng nhập | !user |
Khách vãng lai xem được bình luận nhưng không viết được |
CommentProduct |
Đã đăng nhập | Form chọn số sao (1–5) + nhập nội dung |
CommentList |
Luôn hiện | Danh sách bình luận, có phân trang riêng (4 bình luận/trang) |
Đây là lý do route có thêm biến thể san-pham/:slug/page/:page — yotea-fe/src/App.js:96-103:
{
path: "san-pham/:slug",
element: <ProductDetailPage />,
},
{
path: "san-pham/:slug/page/:page",
element: <ProductDetailPage />,
},
:page ở đây phân trang bình luận, không phải sản phẩm — một chi tiết rất dễ đọc nhầm.
Còn reRender chính là cái "cờ bập bênh" ở mục 3.1: CommentProduct gửi bình luận xong sẽ gọi onReRender((prev) => !prev), giá trị reRender đổi → CommentList thấy prop đổi → tải lại danh sách. Số sao trung bình hiển thị ở đầu trang thì không tự cập nhật (phải F5) vì nó nằm trong state product.
Chi tiết rating, comment, favoritesProduct sẽ được mổ trọn ở Bài 30.
7. Đếm request: mở một trang chi tiết tốn bao nhiêu?
Giờ ta đếm thật từ code, không đoán. Kịch bản: đã đăng nhập, sản phẩm đã có bình luận, có 4 sản phẩm cùng danh mục.
MỞ /san-pham/tra-sua-tran-chau
│
├─ ProductDetailPage useEffect [slug]
│ ① GET /api/products/tra-sua-tran-chau/?_expand=categoryId
│ ② PATCH /api/products/userUpdate/{id} ← tăng view
│ ③ GET /api/ratings/?productId={id} ← getTotalRating
│ ④ GET /api/ratings/?productId={id} ← getAvgStar (TRÙNG ③!)
│
├─ CommentProduct useEffect [productId] (chỉ khi đã đăng nhập)
│ ⑤ GET /api/comments/?productId={id}&_expand=userId&...
│
├─ CommentList useEffect [productId, reRender, ..., page]
│ ⑥ GET /api/comments/?productId={id}&... ← chỉ để ĐẾM
│ ⑦ GET /api/comments/?productId={id}&...&_start&_limit
│ ⑧ GET /api/ratings/?productId={id}&_sort=createdAt&_order=desc
│ └─ setTotalCmt() làm biến `page` bị kẹp lại từ 0 → 1
│ → deps đổi → EFFECT CHẠY LẠI → ⑨ ⑩ ⑪ (lặp lại ⑥⑦⑧)
│
└─ ProductRelated useEffect [id]
⑫ GET /api/products/?categoryId=..&_id_ne=..&status=1&_limit=4
⑬ GET /api/ratings/?productId={sp1}
⑭ GET /api/ratings/?productId={sp2}
⑮ GET /api/ratings/?productId={sp3}
⑯ GET /api/ratings/?productId={sp4}
TỔNG: 16 request cho MỘT lần mở trang.
Ở chế độ dev (StrictMode), phần lớn con số này NHÂN ĐÔI → khoảng 30 dòng trong tab Network.
Chỗ khiến CommentList chạy effect hai lần nằm ở yotea-fe/src/components/user/CommentList.js:23-26:
const limit = 4;
const totalPage = Math.ceil(totalCmt / limit);
page = page < 1 ? 1 : page > totalPage ? totalPage : page;
const start = (page - 1) * limit > 0 ? (page - 1) * limit : 0;
Lần render đầu totalCmt = 0 → totalPage = 0 → biểu thức kẹp trang biến page từ 1 thành 0. Sau khi đếm xong setTotalCmt(N) → totalPage ≥ 1 → page quay lại 1. Vì page nằm trong mảng dependency của useEffect (dòng 56), effect chạy thêm một lượt nữa.
Ba nguyên nhân gốc khiến con số phình to:
| Nguyên nhân | Bằng chứng | Cách chữa |
|---|---|---|
getAvgStar và getTotalRating gọi cùng một URL |
yotea-fe/src/api/rating.js:34-47 — hai hàm, một endpoint |
Gọi một lần rồi tính cả hai giá trị từ mảng nhận về |
| Đếm tổng bằng cách tải hết bản ghi | CommentList.js:30-31, và ở ProductContent.js cũng vậy |
Backend trả tổng số trong header X-Total-Count |
| Vòng lặp N+1: mỗi sản phẩm liên quan là một request sao | ProductRelated.js:17-23 |
Backend trả sẵn ratingNumber kèm sản phẩm, hoặc gọi 1 request cho cả 4 id |
Nếu chữa cả ba, 16 request rút xuống còn 4: lấy sản phẩm, lấy đánh giá, lấy bình luận, lấy sản phẩm liên quan.
💡 Mẹo kiểm chứng: mở DevTools → tab Network → lọc
Fetch/XHR→ F5 trang chi tiết. Đếm số dòng. Cột Waterfall cho thấy các request xếp nối đuôi nhau (vìawaittuần tự) chứ không chạy song song — đó là lý do trang chậm chứ không chỉ vì nhiều request.
8. 🛠️ Tự tay làm — thêm khối "Chọn topping" vào trang chi tiết
Mục tiêu phần này: cuối phần, trang chi tiết sản phẩm có thêm hàng "Topping" ngay dưới hàng "Đường"; người dùng chọn một topping; món thêm vào giỏ mang theo
toppingId,toppingName,toppingPrice; và bạn nhìn thấy ba trường đó nằm tronglocalStorage.
Có một bất ngờ thú vị đang chờ bạn. Mở yotea-fe/src/pages/user/cart/CheckoutPage.js:64-89:
cart.forEach(
async ({
productId,
productPrice,
sizeId,
sizePrice,
quantity,
ice,
sugar,
toppingId,
toppingPrice,
}) => {
await addOrderDetail({
orderId,
productId,
productPrice,
sizeId,
sizePrice,
quantity,
ice,
sugar,
toppingId,
toppingPrice,
});
}
);
Trang thanh toán của dự án đã bóc sẵn toppingId và toppingPrice khỏi mỗi dòng giỏ hàng! Nhưng không có chỗ nào đặt hai trường đó vào giỏ, nên chúng luôn là undefined. Đúng nghĩa là dự án đã chừa sẵn một cái lỗ hình topping — và bạn sắp lấp nó.
Bước 1 — Kiểm tra API Topping đã sẵn sàng
Đứng tại thư mục yotea-be, chạy npm start, rồi mở Postman:
GET http://localhost:8080/api/toppings/?_sort=createdAt&_order=desc
Phải nhận về mảng như sau (nếu rỗng, hãy POST /api/toppings/:userId thêm vài món):
[
{ "_id": "6651a...01", "name": "Trân châu đen", "price": 5000, "status": 1, "slug": "tran-chau-den" },
{ "_id": "6651a...02", "name": "Thạch dừa", "price": 6000, "status": 1, "slug": "thach-dua" },
{ "_id": "6651a...03", "name": "Pudding trứng", "price": 8000, "status": 1, "slug": "pudding-trung" }
]
⚠️ Nhớ lại Bài 05:
statustrongtopping.jscódefault: 0nghĩa là Ẩn. Nếu tất cả topping của bạn đangstatus: 0thì bước dưới sẽ hiện danh sách rỗng. Dùng PostmanPUT /api/toppings/{id}/{userId}đặtstatus: 1cho vài món trước.
Bước 2 — Thêm import và state vào ProductDetailPage.js
Mở yotea-fe/src/pages/user/ProductDetailPage.js. Thêm một dòng import vào cụm import ở đầu file (cạnh dòng 16):
// yotea-fe/src/pages/user/ProductDetailPage.js — dòng BẠN TỰ THÊM
import { getAll as getAllToppings } from "../../api/topping";
💡
getAll as getAllToppingslà đổi tên lúc import — bắt buộc, vì file này đã có sẵngettừapi/product. Nếu không đổi tên, hai cái sẽ đá nhau.
Ngay dưới dòng 35 (const [reRender, setRerender] = useState(false);), thêm hai state:
// code BẠN TỰ VIẾT THÊM — dự án gốc không có
const [toppings, setToppings] = useState([]); // danh sách topping lấy từ API
const [selectedTopping, setSelectedTopping] = useState(null); // topping đang chọn
| Lựa chọn | Vì sao |
|---|---|
useState([]) cho toppings |
Khởi tạo mảng rỗng để .map() không nổ ở lần vẽ đầu tiên |
useState(null) cho selectedTopping |
null nghĩa là "không thêm topping" — đây là lựa chọn mặc định hợp lý |
| Lưu cả object topping chứ không chỉ id | Vì payload cần cả name và price; lưu object thì khỏi phải đi tìm lại |
Bước 3 — Gọi API lấy danh sách topping
Thêm useEffect này ngay dưới useEffect nạp sản phẩm (tức sau dòng 84):
// code BẠN TỰ VIẾT THÊM
useEffect(() => {
const fetchToppings = async () => {
try {
const { data } = await getAllToppings();
// chỉ hiện topping đang bật (status = 1)
setToppings(data.filter((item) => item.status === 1));
} catch (error) {
setToppings([]);
}
};
fetchToppings();
}, []);
| Chi tiết | Vì sao viết vậy |
|---|---|
}, []) |
Danh sách topping không phụ thuộc sản phẩm → chỉ tải một lần khi vào trang |
try/catch |
Backend chết thì trang chi tiết vẫn hiển thị bình thường, chỉ mất hàng topping |
.filter(item => item.status === 1) |
Lọc phía client cho đơn giản. Chuẩn hơn là để backend lọc: getAllToppings() gọi thẳng ?status=1 |
Bước 4 — Vẽ khối chọn topping
Tìm thẻ đóng </div> của khối Đường (dòng 393 trong file gốc — ngay trước <div className="border-b border-dashed pb-4 mt-6"> ở dòng 395). Chèn khối sau vào giữa hai chỗ đó, tức là topping nằm dưới Đường và trên phần số lượng:
{/* ===== KHỐI TOPPING — code BẠN TỰ VIẾT THÊM ===== */}
<div className="flex items-start mt-2">
<label className="min-w-[80px] font-bold text-sm pt-1">Topping</label>
<ul className="flex flex-wrap">
<li>
<button
type="button"
onClick={() => setSelectedTopping(null)}
className={`block cursor-pointer px-3 py-1 border-2 rounded-[4px] mr-1 mb-1 shadow-sm text-sm transition duration-300 hover:shadow-md ${
selectedTopping === null
? "border-[#D9A953] text-[#D9A953] font-semibold"
: "border-gray-300 text-gray-500"
}`}
>
Không
</button>
</li>
{toppings.map((topping) => (
<li key={topping._id}>
<button
type="button"
onClick={() => setSelectedTopping(topping)}
className={`block cursor-pointer px-3 py-1 border-2 rounded-[4px] mr-1 mb-1 shadow-sm text-sm transition duration-300 hover:shadow-md ${
selectedTopping?._id === topping._id
? "border-[#D9A953] text-[#D9A953] font-semibold"
: "border-gray-300 text-gray-500"
}`}
>
{topping.name} +{formatCurrency(topping.price)}
</button>
</li>
))}
</ul>
</div>
{/* ===== HẾT KHỐI TOPPING ===== */}
| Chi tiết | Vì sao bắt buộc phải vậy |
|---|---|
type="button" |
🔴 Quan trọng nhất. Khối này nằm trong thẻ <form> (mở ở dòng 219). Thiếu type="button", bấm chọn topping sẽ submit form và thêm hàng vào giỏ ngay lập tức |
key={topping._id} |
Khoá duy nhất cho mỗi phần tử của .map() |
selectedTopping?._id === topping._id |
Dấu ?. phòng khi selectedTopping là null (lựa chọn "Không") |
formatCurrency(topping.price) |
Dùng lại hàm có sẵn ở yotea-fe/src/utils/index.js (đã import ở dòng 17) → hiện 5.000 ₫ |
Dùng <button> + useState thay vì radio + register |
Đề bài yêu cầu lưu bằng useState; cách này cũng tránh được lỗi reset() làm bay lựa chọn (xem Bước 6) |
Bước 5 — Đưa topping vào payload addCart
Sửa hàm onSubmit (dòng 39-62). Đây là phiên bản đầy đủ sau khi sửa — ba dòng mới được đánh dấu:
// yotea-fe/src/pages/user/ProductDetailPage.js — PHIÊN BẢN SAU KHI BẠN SỬA
const onSubmit = async ({ ice, sugar }) => {
// get data product
const { data: product } = await get(slug);
const productData = {
productSlug: product.slug,
productId: product._id,
productName: product.name,
productPrice: product.price,
productImage: product.image,
};
const cartData = {
id: uuidv4(),
...productData,
quantity,
ice: +ice,
sugar: +sugar,
toppingId: selectedTopping?._id || "", // ← MỚI
toppingName: selectedTopping?.name || "", // ← MỚI
toppingPrice: selectedTopping?.price || 0, // ← MỚI
};
dispatch(addCart(cartData));
toast.success(`Thêm ${product.name} vào giỏ hàng thành công`);
reset();
setQuantity(1);
setSelectedTopping(null); // ← MỚI: trả nút về "Không"
};
| Trường mới | Kiểu | Khi không chọn topping | Dùng ở đâu về sau |
|---|---|---|---|
toppingId |
chuỗi | "" |
CheckoutPage.js:73 đã bóc sẵn — gửi lên OrderDetail |
toppingName |
chuỗi | "" |
Hiển thị trong giỏ hàng (Bài 27) mà không phải gọi API |
toppingPrice |
số | 0 |
Cộng vào tổng tiền |
💡 Vì sao dùng
""và0chứ không phảiundefined? VìJSON.stringifyxoá sạch các trường có giá trịundefined. Redux-persist lưu bằng JSON, nêntoppingId: undefinedsẽ biến mất khỏi localStorage, gây khó khi debug.
Bước 6 — Sửa khoá gộp món trong cartSlice.js
Đây là bước dễ bị bỏ quên nhất. Hiện tại addCart gộp món trùng theo productId + ice + sugar. Nghĩa là:
Thêm: Trà sữa, 100% đá, 100% đường, TRÂN CHÂU
Thêm: Trà sữa, 100% đá, 100% đường, PUDDING
→ Bị gộp thành 1 dòng số lượng 2, và topping của lần thêm thứ hai BỐC HƠI!
Mở yotea-fe/src/redux/cartSlice.js và thêm một dòng vào điều kiện find:
// yotea-fe/src/redux/cartSlice.js — dòng cuối trong find() là BẠN TỰ THÊM
addCart({ cart }, { payload: newProduct }) {
const exitsProduct = cart.find(
(item) =>
item.productId === newProduct.productId &&
item.ice === newProduct.ice &&
item.sugar === newProduct.sugar &&
item.toppingId === newProduct.toppingId // ← MỚI
);
⚠️ Đây là lần đầu ta sửa file có sẵn của dự án trong mạch thực hành. Hãy
git diffđể thấy rõ mình đã đổi gì; nếu sau này muốn quay lại nguyên bản,git checkout -- src/redux/cartSlice.js.
9. ✅ Kiểm chứng kết quả
9.1. Nhìn thấy request lấy topping
Đứng tại yotea-fe, chạy:
npm start
Mở http://localhost:3000/san-pham/<slug-sản-phẩm-của-bạn>, mở DevTools → Network → lọc Fetch/XHR. Ngoài các request đã đếm ở mục 7, phải có thêm một dòng:
| Cột | Giá trị mong đợi |
|---|---|
| Name | toppings/?_sort=createdAt&_order=desc |
| Status | 200 |
| Request URL | http://localhost:8080/api/toppings/?_sort=createdAt&_order=desc |
9.2. Nhìn thấy khối topping
Dưới hàng "Đường" phải xuất hiện hàng "Topping" gồm nút Không và các nút topping kèm giá:
Đá [0%] [30%] [50%] [70%] [100%]
Đường [0%] [30%] [50%] [70%] [100%]
Topping [Không] [Trân châu đen +5.000 ₫] [Thạch dừa +6.000 ₫] [Pudding trứng +8.000 ₫]
Bấm vào một topping → nút đó đổi sang viền vàng #D9A953, các nút khác trở lại xám. Trang không được reload — nếu trang nhảy lên đầu hoặc có toast "Thêm ... vào giỏ hàng" thì bạn đã quên type="button".
9.3. Kiểm chứng bằng Redux state trong localStorage — bước quan trọng nhất
- Chọn: 50% đá, 30% đường, topping Pudding trứng, số lượng 2 → bấm Thêm vào giỏ hàng.
- DevTools → tab Application → Local Storage →
http://localhost:3000→ khoápersist:root. - Bạn thấy một chuỗi JSON dài. Copy giá trị của khoá
cartra rồi format lại, hoặc nhanh hơn: dán lệnh này vào Console:
JSON.parse(JSON.parse(localStorage.getItem("persist:root")).cart).cart
Kết quả phải là:
[
{
"id": "0d3f9c8e-7b31-4a2f-9c1d-5e6f7a8b9c0d",
"productSlug": "tra-sua-tran-chau-duong-den",
"productId": "6650a1f2c4e8b91234abcd01",
"productName": "Trà sữa trân châu đường đen",
"productPrice": 35000,
"productImage": "https://res.cloudinary.com/levantuan/image/upload/...",
"quantity": 2,
"ice": 50,
"sugar": 30,
"toppingId": "6651a...03",
"toppingName": "Pudding trứng",
"toppingPrice": 8000
}
]
Danh sách phải soát đủ 11 trường — 8 trường gốc + 3 trường topping bạn vừa thêm.
- Thêm cùng sản phẩm, cùng đá/đường nhưng đổi topping sang Trân châu đen → mảng phải có 2 phần tử, mỗi phần tử một
toppingIdkhác nhau. Nếu chỉ thấy 1 phần tử vớiquantitycộng dồn thì Bước 6 chưa được làm. - F5 trang → chạy lại lệnh Console trên → dữ liệu vẫn còn nguyên. Đó chính là
redux-persistbạn đã học ở Bài 21 đang làm việc.
10. 🐞 Lỗi thường gặp
| Thông báo / hiện tượng | Nguyên nhân | Cách sửa |
|---|---|---|
| Bấm nút topping thì trang nhảy lên đầu, hiện toast "Thêm ... thành công" | Quên type="button" → nút mặc định là submit của <form> |
Thêm type="button" cho mọi nút bên trong form |
Cannot read properties of undefined (reading 'map') |
useState() rỗng thay vì useState([]) cho toppings |
Khởi tạo bằng mảng rỗng |
| Hàng Topping hiện ra nhưng không có nút nào ngoài "Không" | Mọi topping đang status: 0 (mặc định là Ẩn) |
Postman PUT /api/toppings/{id}/{userId} đặt status: 1, hoặc tạm bỏ .filter(...) |
404 Not Found khi gọi /api/toppings |
DB_NAME trong api/topping.js không khớp route backend |
Mở yotea-be/src/routes/topping.js đối chiếu (/toppings hay /topping) |
Identifier 'get' has already been declared |
Import get từ api/topping mà file đã có get của api/product |
Đổi tên: import { getAll as getAllToppings } |
Vào trang chi tiết thấy view tăng 2 mỗi lần |
StrictMode chạy useEffect hai lần ở chế độ dev |
Đây là hành vi đúng của React; muốn hết thì phải sửa theo mục 3.3 |
| Trang chi tiết trắng trơn, không lỗi đỏ | Slug không tồn tại → backend trả 200 kèm null → product là null |
Kiểm tra slug; sửa backend trả 404 (mục 2) |
toppingId biến mất khỏi localStorage |
Gán undefined — JSON.stringify xoá trường undefined |
Dùng || "" và || 0 |
| Thêm hai topping khác nhau nhưng giỏ chỉ có 1 dòng | Chưa sửa khoá gộp trong cartSlice.js |
Làm Bước 6 |
Warning: Each child in a list should have a unique "key" prop |
Thiếu key khi .map() |
Thêm key={topping._id} |
11. 📝 Bài tập
Bài 1. Hiển thị tạm tính ngay phía trên nút "Thêm vào giỏ hàng": (giá sản phẩm + giá topping) × số lượng, định dạng tiền Việt.
Không cần thêm state mới — mọi thứ cần thiết đã có trong product, selectedTopping, quantity. Đây là giá trị dẫn xuất (derived value), chỉ cần tính lúc render:
{/* code BẠN TỰ VIẾT THÊM — đặt ngay trên nút "Thêm vào giỏ hàng" */}
<p className="mt-2 text-xl font-semibold">
Tạm tính:{" "}
{formatCurrency(
((product?.price || 0) + (selectedTopping?.price || 0)) * quantity
)}
</p>
💡 Nguyên tắc vàng của state: cái gì tính ra được từ state có sẵn thì đừng tạo state mới. Nếu bạn tạo
const [totalPrice, setTotalPrice] = useState(0)rồi phải nhớsetTotalPriceở cảhandleIncrease,handleDecrease,onChangevàsetSelectedTopping— chỉ cần quên một chỗ là hiện sai tiền. Nhìn lại dòng 396 của file gốc: dự án từng thử làm kiểu đó rồi comment lại bỏ đi.
Lưu ý: con số này mới chỉ đúng ở trang chi tiết. Muốn tổng tiền toàn giỏ cũng cộng topping thì phải sửa công thức reduce trong CartPage.js và CheckoutPage.js — việc đó để dành cho Bài 27 và Bài 28.
Bài 2. Viết lại cơ chế tăng lượt xem cho đúng: backend tự cộng bằng $inc, frontend không gửi con số nào, và ở chế độ dev cũng chỉ tăng đúng 1 cho mỗi lần mở trang.
Backend — thêm controller (yotea-be/src/controllers/product.js, code bạn tự viết thêm):
export const increaseView = async (req, res) => {
try {
const product = await Product.findOneAndUpdate(
{ slug: req.params.slug },
{ $inc: { view: 1 } },
{ new: true }
).exec();
if (!product) {
return res.status(404).json({ message: "Không tìm thấy sản phẩm" });
}
res.json({ view: product.view });
} catch (error) {
res.status(400).json({ message: "Cập nhật lượt xem thất bại", error });
}
};
Backend — thêm route (yotea-be/src/routes/product.js). ⚠️ Phải đặt trước router.get("/products/:slug", read)? Không — đây là PATCH còn kia là GET nên không đụng nhau; nhưng nó phải khác /products/userUpdate/:id để không nhập nhằng:
import { clientUpdate, create, increaseView, list, read, remove, update } from "../controllers/product";
router.patch("/products/increaseView/:slug", increaseView);
Frontend — thêm hàm API (yotea-fe/src/api/product.js):
export const increaseView = (slug) => {
const url = `/${DB_NAME}/increaseView/${slug}`;
return instance.patch(url); // KHÔNG gửi body — server tự cộng
};
Frontend — chống StrictMode chạy 2 lần (ProductDetailPage.js):
import { useRef } from "react"; // thêm vào import có sẵn
const viewedRef = useRef(null);
useEffect(() => {
if (viewedRef.current === slug) return; // lượt chạy thứ 2 thoát ngay
viewedRef.current = slug;
increaseView(slug).catch(() => {}); // lỗi lượt xem không được làm hỏng trang
}, [slug]);
Và xoá hai dòng 71-72 (data.view++ và await clientUpdate(data)) khỏi effect nạp sản phẩm.
Vì sao useRef chứ không phải useState? Vì đổi useState sẽ làm component vẽ lại; useRef giữ giá trị qua các lần render mà không kích hoạt render — đúng nhu cầu "ghi nhớ đã làm rồi".
Vẫn còn một điểm chưa hoàn hảo: người dùng F5 100 lần vẫn tăng 100 view. Muốn chặt chẽ hơn thì backend phải ghi nhận IP hoặc session — vượt ngoài phạm vi giáo trình, nhưng bạn nên biết đó là cách các trang thật làm.
</details>Bài 3. Ở mục 4 ta đã nói useEffect của ProductRelated thiếu cateId trong mảng dependency. Hãy tái hiện lỗi đó (làm nó vỡ thật), rồi sửa.
Tái hiện: tách setProduct trong ProductDetailPage thành hai lần cập nhật để id tới trước cateId:
// code TẠM để tái hiện lỗi — nhớ xoá sau khi thử xong
setProduct({ ...data, categoryId: undefined }); // id có, cateId chưa có
setTimeout(() => setProduct({ ...data }), 1000); // 1 giây sau cateId mới tới
Kết quả: khối "Sản phẩm tương tự" trống vĩnh viễn. Lý do:
| Lần render | id |
cateId |
Effect chạy? | Kết quả |
|---|---|---|---|---|
| 1 | undefined |
undefined |
Có (mount) | cateId && chặn → không gọi API |
| 2 | 6650... |
undefined |
Có (id đổi) |
cateId && chặn → không gọi API |
| 3 | 6650... |
6650... |
KHÔNG (id không đổi) |
Mãi mãi trống |
Sửa:
cateId && getProducts();
}, [id, cateId]); // ← thêm cateId
Quy tắc rút ra: mọi giá trị bên ngoài mà thân useEffect đọc tới đều phải có mặt trong mảng dependency. Bật ESLint rule react-hooks/exhaustive-deps để máy nhắc giúp bạn — dự án Yotea vi phạm quy tắc này ở hơn 10 chỗ, ta sẽ dọn ở Bài 34.
📌 Tóm tắt
- Trang chi tiết đi theo đường:
URL /san-pham/:slug→useParams→api/product.js get(slug)→GET /api/products/:slug/?_expand=categoryId→routes/product.js:9→controllers/product.js:73-89→Product.findOne({ slug })→ MongoDB, rồires.json→ axios →setProduct→ React vẽ lại. - Dùng slug thay vì
_idđể URL đọc được, tốt cho SEO và không lộ id nội bộ — thành quả của Bài 08. - Đá và đường là hai nhóm radio ẩn (
0/30/50/70/100, mặc định100) do react-hook-form quản; số lượng douseStatequản. - Payload
addCartcó 8 trường, trong đóidsinh bằng uuid để phân biệt các dòng giỏ hàng — không phải để phân biệt sản phẩm. - Cơ chế tăng lượt xem của dự án sai ở ba tầng: StrictMode đếm 2 lần, endpoint
PATCH /products/userUpdate/:idkhông có auth, và read-modify-write làm mất lượt xem. Cách đúng gọn hơn nhiều:{ $inc: { view: 1 } }ở backend. ProductRelatedlọc cùngcategoryId, loại chính nó bằng_id_ne→$nin,status=1,_limit=4.- Mở một trang chi tiết bắn đi khoảng 16 request (dev còn gần gấp đôi) — do URL trùng lặp, đếm bằng cách tải hết, và vòng lặp N+1.
- Bạn đã nối Topping vào luồng mua hàng:
api/topping.js→useState→ khối chọn → payloadaddCart→localStorage.
Từ khoá tra cứu thêm: React StrictMode double render, MongoDB $inc atomic update, read-modify-write race condition, uuid v4, controlled input React, react-hook-form register radio, N+1 query problem, useEffect dependency array
➡️ Bài tiếp theo: 27 — Giỏ hàng — cái mảng vừa nằm trong localStorage sẽ hiện lên thành bảng có nút tăng giảm và tổng tiền; và bạn sẽ thấy vì sao addCart gộp theo một khoá còn removeItemCart xoá theo khoá khác lại là một quả bom thật sự.
All rights reserved