# Bài 16 — Trang giỏ hàng & đặt hàng
⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️
🎯 Mục tiêu
- Dựng bảng giỏ hàng: sửa số lượng, xóa sản phẩm, tính tổng tiền
- Dùng Modal của Bootstrap để nhập thông tin giao hàng
- Gửi đơn hàng lên API và xử lý kết quả
- Sửa lỗi công thức tính tiền theo size — không nhất quán trong source gốc
⚠️ 1. Vấn đề công thức tính tiền theo size
Đây là đoạn trong source gốc, trong cột "Đơn Giá":
{product.size === 'l'
? (product.price * product.quantity + (product.quantity * 10000)).toLocaleString()
: product.size === 'm'
? (product.price * product.quantity + 15000).toLocaleString()
: (product.price * product.quantity).toLocaleString()}
Viết lại cho dễ đọc:
| Size | Công thức | Phụ phí thực tế |
|---|---|---|
s |
price × quantity |
0 |
m |
price × quantity + 15.000 |
15.000đ cho cả dòng |
l |
price × quantity + quantity × 10.000 |
10.000đ mỗi sản phẩm |
Ba vấn đề
a) Không nhất quán: size m cộng cố định 1 lần, size l cộng theo số lượng.
Mua 5 chai size M chỉ tốn thêm 15k, mua 5 chai size L tốn thêm 50k.
Không có logic kinh doanh nào giải thích được điều này.
b) Size L rẻ hơn size M khi mua ít: mua 1 chai — size M tốn thêm 15k, size L chỉ tốn thêm 10k. Size lớn hơn mà rẻ hơn?
c) Dòng tổng cộng KHÔNG tính phụ phí:
<tfoot>
<tr>
<td colSpan="3" className="text-end">tổng tiềng:</td>
<td>{cart.reduce((total, product) => total + product.quantity * product.price, 0).toLocaleString()}đ</td>
</tr>
</tfoot>
Chỉ có quantity * price — bỏ qua hoàn toàn phụ phí size.
Và biến total khai báo ở đầu component cũng vậy:
const total = cart.reduce((total, item) => total + item.price * item.quantity, 0);
→ Số tiền ở cột "Đơn Giá" cộng lại KHÁC số tiền ở dòng "Tổng tiền", và số gửi lên server là số thiếu phụ phí.
Khách hàng cộng nhẩm sẽ phát hiện ra ngay và mất niềm tin.
✅ Cách sửa: một nguồn sự thật duy nhất
Tạo một hàm tính tiền dùng chung, gọi ở cả 3 nơi: cột đơn giá, dòng tổng, và dữ liệu gửi lên server.
Tạo frontend/lib/price.js:
// Phụ phí theo size — TÍNH TRÊN MỖI SẢN PHẨM
export const SIZE_FEE = {
s: 0,
m: 15000,
l: 30000,
};
// Tiền của 1 dòng trong giỏ
export function lineTotal(item) {
const fee = SIZE_FEE[item.size] ?? 0;
return (item.price + fee) * item.quantity;
}
// Tổng tiền cả giỏ
export function cartTotal(cart) {
return cart.reduce((sum, item) => sum + lineTotal(item), 0);
}
// Định dạng tiền Việt
export function formatVND(n) {
return Number(n).toLocaleString("vi-VN") + "đ";
}
💡 Bảng phụ phí này phải khớp với hằng
SIZE_FEEở backend (bài 08 mục 5). Nếu hai bên lệch nhau, số tiền hiển thị sẽ khác số tiền server tính và lưu.Trong dự án thật, nên để giá theo size trong database (mỗi sản phẩm có mảng
variants: [{ size, price }]) thay vì hardcode. Bài tập 5 sẽ hướng dẫn.
💻 2. Trang giỏ hàng hoàn chỉnh
frontend/app/(user)/cart/page.js:
"use client"
import { useDispatch, useSelector } from "react-redux";
import { removeCart, removeItem, updateItem } from "@/redux/slices/cartSlicer";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { API, IMG } from "@/lib/fetcher";
import { lineTotal, cartTotal, formatVND, SIZE_FEE } from "@/lib/price";
export default function Cart() {
const cart = useSelector((state) => state.cart);
const dispatch = useDispatch();
const router = useRouter();
const total = cartTotal(cart);
const [fullname, setFullname] = useState("");
const [phone, setPhone] = useState("");
const [address, setAddress] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const closeBtn = useRef();
const submit = async (e) => {
e.preventDefault();
setErrorMsg("");
// Kiểm tra phía client trước
if (!fullname.trim()) return setErrorMsg("Vui lòng nhập họ tên");
if (!/^0\d{9,10}$/.test(phone.trim())) return setErrorMsg("Số điện thoại không hợp lệ");
if (!address.trim()) return setErrorMsg("Vui lòng nhập địa chỉ");
setSubmitting(true);
try {
const res = await fetch(`${API}/orders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user: { fullname, phone, address },
detail: cart,
total_money: total,
}),
});
const result = await res.json();
if (!res.ok) {
setErrorMsg(result.message || "Đặt hàng thất bại");
return;
}
// Thành công
dispatch(removeCart());
setFullname("");
setPhone("");
setAddress("");
closeBtn.current?.click();
alert(`Đặt hàng thành công! Tổng tiền: ${formatVND(result.total_money ?? total)}`);
router.push("/");
} catch (err) {
setErrorMsg("Không kết nối được tới máy chủ. Vui lòng thử lại.");
} finally {
setSubmitting(false);
}
};
// ===== Giỏ hàng trống =====
if (cart.length === 0) {
return (
<div className="container mt-5 text-center">
<h1>Giỏ Hàng</h1>
<p className="text-muted my-4">Giỏ hàng của bạn đang trống</p>
<Link href="/menu" className="btn btn-dark">Tiếp tục mua sắm</Link>
</div>
);
}
return (
<div className="container mt-5">
<h1>Giỏ Hàng</h1>
<table className="table align-middle">
<thead>
<tr>
<th>Sản phẩm</th>
<th>Size</th>
<th>Giá bán</th>
<th>Số lượng</th>
<th>Thành tiền</th>
<th>Thao tác</th>
</tr>
</thead>
<tbody>
{cart.map((product) => (
<tr key={`${product._id}-${product.size}`}>
<td>
<img
className="img-thumbnail me-3"
style={{ width: 48 }}
src={`${IMG}${product.image}`}
alt={product.name}
/>
<strong>{product.name}</strong>
</td>
<td>
{product.size.toUpperCase()}
{SIZE_FEE[product.size] > 0 && (
<small className="text-muted d-block">
+{formatVND(SIZE_FEE[product.size])}/sp
</small>
)}
</td>
<td>{formatVND(product.price)}</td>
<td>
<input
type="number"
min="1"
className="form-control m-auto"
style={{ width: 100 }}
value={product.quantity}
onChange={(e) =>
dispatch(updateItem({
product,
quantity: e.target.value,
size: product.size,
}))
}
/>
</td>
<td className="fw-bold">{formatVND(lineTotal(product))}</td>
<td>
<button
onClick={() => dispatch(removeItem({ product, size: product.size }))}
className="btn btn-sm btn-outline-danger"
>
<i className="fa-solid fa-trash"></i> Xóa
</button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan="4" className="text-end fw-bold">Tổng tiền:</td>
<td className="fw-bold fs-5 text-danger">{formatVND(total)}</td>
<td></td>
</tr>
</tfoot>
</table>
<div className="d-flex gap-2">
<button
type="button"
className="btn btn-dark"
data-bs-toggle="modal"
data-bs-target="#orderModal"
>
Đặt Hàng
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => {
if (confirm("Xóa toàn bộ giỏ hàng?")) dispatch(removeCart());
}}
>
Xóa giỏ hàng
</button>
</div>
{/* ===== Modal thông tin giao hàng ===== */}
<div
className="modal fade"
id="orderModal"
tabIndex="-1"
aria-labelledby="orderModalLabel"
aria-hidden="true"
>
<div className="modal-dialog">
<form className="modal-content" onSubmit={submit}>
<div className="modal-header">
<h5 className="modal-title" id="orderModalLabel">Thông Tin Giao Hàng</h5>
<button
type="button"
className="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
ref={closeBtn}
></button>
</div>
<div className="modal-body">
{errorMsg && <div className="alert alert-danger py-2">{errorMsg}</div>}
<div className="mb-3">
<label htmlFor="fullname" className="form-label">Họ Tên *</label>
<input
type="text"
className="form-control"
id="fullname"
value={fullname}
onChange={(e) => setFullname(e.target.value)}
/>
</div>
<div className="mb-3">
<label htmlFor="phone" className="form-label">Số Điện Thoại *</label>
<input
type="tel"
className="form-control"
id="phone"
placeholder="0901234567"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<div className="mb-3">
<label htmlFor="address" className="form-label">Địa Chỉ *</label>
<input
type="text"
className="form-control"
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</div>
<div className="border-top pt-3">
<div className="d-flex justify-content-between">
<span>Tổng cộng:</span>
<strong className="text-danger fs-5">{formatVND(total)}</strong>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" data-bs-dismiss="modal">
Hủy
</button>
<button type="submit" className="btn btn-success" disabled={submitting}>
{submitting ? "Đang xử lý..." : "Xác Nhận Thanh Toán"}
</button>
</div>
</form>
</div>
</div>
</div>
);
}
🔍 3. Giải thích các điểm quan trọng
key phải kết hợp _id và size
<tr key={`${product._id}-${product.size}`}>
Source gốc chỉ dùng key={product._id}. Cùng một sản phẩm với 2 size là 2 dòng
cùng key → React cảnh báo Encountered two children with the same key
và có thể render sai khi bạn xóa một dòng.
Input số lượng phải là controlled
value={product.quantity}
onChange={(e) => dispatch(updateItem({ product, quantity: e.target.value, size: product.size }))}
Source gốc dùng defaultValue={product.quantity} — uncontrolled.
Hậu quả: sau khi removeCart() xóa giỏ, nếu component không unmount,
các ô input vẫn giữ số cũ.
Với value, React luôn đồng bộ input với Redux state.
closeBtn.current?.click() — đóng modal bằng code
Bootstrap Modal được điều khiển bằng JavaScript của Bootstrap, không phải React. Cách đơn giản nhất để đóng nó từ React là "bấm hộ" nút close:
const closeBtn = useRef();
<button ref={closeBtn} data-bs-dismiss="modal"></button>
// ...
closeBtn.current?.click();
Toán tử ?. (optional chaining) tránh crash nếu current là null.
💡 Cách "chuẩn React" hơn là dùng thư viện
react-bootstrap, có<Modal show={...}>điều khiển bằng state. Nhưng cách trên đơn giản và đủ dùng.
disabled={submitting} — chống bấm nhiều lần
Source gốc không có. Người dùng bấm nhanh 3 lần → tạo 3 đơn hàng giống nhau.
Kiểm tra res.ok trước khi coi là thành công
Source gốc:
.then(res => {
alert("đặt hàng thành công ") // ← LUÔN LUÔN hiện, kể cả khi lỗi
dispatch(removeCart()),
...
})
fetch không ném lỗi khi server trả 400/500. Nên dù backend từ chối đơn hàng,
frontend vẫn báo "thành công" và xóa sạch giỏ hàng.
Khách mất giỏ hàng mà đơn không được tạo — lỗi nghiêm trọng nhất trong trang này.
Bản trên kiểm tra res.ok trước.
💡 Cũng để ý dấu phẩy trong
dispatch(removeCart()),của source gốc — đó là toán tử dấu phẩy của JavaScript, không phải lỗi cú pháp, nhưng chắc chắn là gõ nhầm (đáng lẽ là dấu chấm phẩy).
try/catch + finally
catch— bắt lỗi mạng (backend tắt, mất kết nối)finally— luôn tắt trạng tháisubmittingdù thành công hay thất bại
Xử lý giỏ hàng trống
Source gốc không có → hiện bảng trống với dòng "tổng tiềng: 0đ", nút Đặt Hàng vẫn bấm được và tạo đơn rỗng.
📚 4. Bootstrap Modal hoạt động thế nào?
{/* Nút mở */}
<button data-bs-toggle="modal" data-bs-target="#orderModal">Đặt Hàng</button>
{/* Modal */}
<div className="modal fade" id="orderModal" tabIndex="-1">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">...</div>
<div className="modal-body">...</div>
<div className="modal-footer">...</div>
</div>
</div>
</div>
Yêu cầu bắt buộc:
data-bs-target="#orderModal"phải khớp vớiid="orderModal"bootstrap.bundle.min.jsphải được nạp (bài 10)- Nút đóng dùng
data-bs-dismiss="modal"
⚠️ Lỗi cấu trúc trong source gốc
<div className="modal fade" id="exampleModal">
<form className="modal-dialog" onSubmit={submit}> {/* ⚠️ form thay cho div */}
<div className="modal-content">
Đặt <form> ở vị trí modal-dialog khiến CSS của Bootstrap
(căn giữa, animation) hoạt động không đúng.
Đúng ra: giữ <div className="modal-dialog">, và đặt <form> ở vị trí
modal-content (như bản trên) để nút submit trong footer vẫn thuộc form.
⚠️ Modal không mở được?
Nếu bấm nút mà không có gì xảy ra:
- Kiểm tra bootstrap JS đã nạp chưa — Console gõ
bootstrapphải ra object - Kiểm tra đường dẫn script — phải là
/js/bootstrap.bundle.min.js(có/đầu). Đây chính là lỗi ở bài 10: source gốc viếtjs/bootstrap...nên ở trang/carttrình duyệt tìm/cart/js/bootstrap...→ 404 → modal không hoạt động. - Kiểm tra
idvàdata-bs-targetcó khớp
✅ 5. Kiểm thử
Test luồng đầy đủ
- Thêm 2 sản phẩm khác nhau vào giỏ, một cái size S, một cái size L
- Vào
/cart - ✅ Bảng hiện 2 dòng, có ảnh, tên, size, giá
- ✅ Cột "Thành tiền" của dòng size L =
(giá + 30.000) × số lượng - ✅ Dòng "Tổng tiền" = tổng của cột "Thành tiền" — cộng nhẩm phải khớp
- Đổi số lượng → cả "Thành tiền" và "Tổng tiền" cập nhật ngay
- Bấm "Xóa" một dòng → dòng biến mất, tổng tiền giảm
- Bấm "Đặt Hàng" → modal mở ra, hiện tổng tiền đúng
- Điền thông tin, bấm "Xác Nhận Thanh Toán"
- ✅ Alert "Đặt hàng thành công", modal đóng, giỏ hàng rỗng, chuyển về trang chủ
- Mở MongoDB Compass → collection
orderscó đơn mới
Test các trường hợp lỗi
| Thử gì | Kết quả mong đợi |
|---|---|
| Bỏ trống họ tên | Alert đỏ "Vui lòng nhập họ tên", modal không đóng |
SĐT = abc |
Alert đỏ "Số điện thoại không hợp lệ" |
| Tắt backend rồi đặt hàng | Alert đỏ "Không kết nối được tới máy chủ", giỏ hàng vẫn còn |
| Bấm nút submit liên tục | Nút bị disable, chỉ 1 đơn được tạo |
Vào /cart khi giỏ rỗng |
"Giỏ hàng của bạn đang trống" + nút tiếp tục mua sắm |
❗ Test "tắt backend" là quan trọng nhất. Với source gốc, giỏ hàng bị xóa sạch dù đơn không được tạo. Với bản này, giỏ hàng được giữ nguyên.
Kiểm tra tính nhất quán frontend–backend
- Đặt một đơn có sản phẩm size
l - Xem
total_moneytrong MongoDB - So sánh với số hiển thị ở trang giỏ hàng
- ✅ Phải bằng nhau (nếu
SIZE_FEEhai bên khớp)
📝 Bài tập
-
Trang "Đặt hàng thành công" riêng ở
/order-success/[id]thay choalert(), hiển thị mã đơn và thông tin giao hàng. -
Nút tăng/giảm số lượng thay cho ô nhập số:
<button onClick={() => dispatch(updateItem({...product, quantity: product.quantity - 1}))}>−</button> -
Phí vận chuyển: miễn phí nếu tổng > 5 triệu, ngược lại 30.000đ. Hiển thị thành 3 dòng: Tạm tính / Phí vận chuyển / Tổng cộng. Nhớ cập nhật cả backend.
-
Mã giảm giá: nhập
LITH10giảm 10%. Validate ở backend, không tin client. -
Giá theo size từ database: thêm vào mỗi sản phẩm trong MongoDB:
"variants": [ { "size": "s", "volume": "50ml", "price": 3000000 }, { "size": "m", "volume": "100ml", "price": 5050000 }, { "size": "l", "volume": "200ml", "price": 8000000 } ]Rồi bỏ hằng
SIZE_FEE, lấy giá trực tiếp từvariants. Đây là cách làm đúng trong dự án thật. -
Tìm hiểu bug tự thân: với source gốc, thêm 3 chai size M vào giỏ. Tính tay: cột "Đơn giá" hiện bao nhiêu? Dòng "Tổng tiền" hiện bao nhiêu? Số nào được gửi lên server? Viết ra giấy trước khi kiểm tra.
All rights reserved