0

# 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ái submitting dù 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ới id="orderModal"
  • bootstrap.bundle.min.js phả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:

  1. Kiểm tra bootstrap JS đã nạp chưa — Console gõ bootstrap phải ra object
  2. 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ết js/bootstrap... nên ở trang /cart trình duyệt tìm /cart/js/bootstrap... → 404 → modal không hoạt động.
  3. Kiểm tra id và data-bs-target có khớp

✅ 5. Kiểm thử

Test luồng đầy đủ

  1. 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
  2. Vào /cart
  3. ✅ Bảng hiện 2 dòng, có ảnh, tên, size, giá
  4. ✅ Cột "Thành tiền" của dòng size L = (giá + 30.000) × số lượng
  5. ✅ Dòng "Tổng tiền" = tổng của cột "Thành tiền" — cộng nhẩm phải khớp
  6. Đổi số lượng → cả "Thành tiền" và "Tổng tiền" cập nhật ngay
  7. Bấm "Xóa" một dòng → dòng biến mất, tổng tiền giảm
  8. Bấm "Đặt Hàng" → modal mở ra, hiện tổng tiền đúng
  9. Điền thông tin, bấm "Xác Nhận Thanh Toán"
  10. ✅ Alert "Đặt hàng thành công", modal đóng, giỏ hàng rỗng, chuyển về trang chủ
  11. Mở MongoDB Compass → collection orders có đơ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

  1. Đặt một đơn có sản phẩm size l
  2. Xem total_money trong MongoDB
  3. So sánh với số hiển thị ở trang giỏ hàng
  4. ✅ Phải bằng nhau (nếu SIZE_FEE hai bên khớp)

📝 Bài tập

  1. Trang "Đặt hàng thành công" riêng ở /order-success/[id] thay cho alert(), hiển thị mã đơn và thông tin giao hàng.

  2. Nút tăng/giảm số lượng thay cho ô nhập số:

    <button onClick={() => dispatch(updateItem({...product, quantity: product.quantity - 1}))}>−</button>
    
  3. 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.

  4. Mã giảm giá: nhập LITH10 giảm 10%. Validate ở backend, không tin client.

  5. 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.

  6. 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.


⬅️ Bài trước | Mục lục | Bài tiếp theo: Khu quản trị ➡️


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí