0

Bài 32 — Trang quản trị: CRUD, phân trang, upload ảnh Cloudinary Phần 2

9.3. BẢNG GIÁ TRỊ status ↔ NHÃN TRẠNG THÁI

Lấy đúng từ code — cột "nhãn ngắn" từ OrderList.js:108-116, cột "nhãn dài" từ CartDetailPage.js:127-137, cột "nút" từ CartDetailPage.js:70-108, và cột "dropdown tìm kiếm" từ CartListPage.js:53-59:

status Nhãn ở bảng (OrderList) Nhãn ở trang chi tiết (CartDetailPage) Nút chuyển tiếp Nhãn trong dropdown (CartListPage) Màu badge
0 Đơn hàng mới Đang chờ xác nhận Xác nhận ĐH → handleUpdateStt(1) Đơn hàng mới xanh
1 Đã xác nhận Đã xác nhận lúc <thời gian> Đang giao hàng → handleUpdateStt(2) Đã xác nhận xanh
2 Đang giao hàng Đang giao hàng lúc <thời gian> Đã giao hàng → handleUpdateStt(3) Đang giao hàng xanh
3 Đã giao hàng Đã giao thành công lúc <thời gian> (hết đường) Đã giao hoàng ⚠️ sai chính tả xanh
4 Đã hủy Đã bị hủy lúc <thời gian> (hết đường) Đã hủy đỏ

Nút Hủy ĐH → handleUpdateStt(4), chỉ hiện khi order.status !== 4 && order.status !== 3 (CartDetailPage.js:98).

⚠️ {!item.status ? "Đơn hàng mới" : ...} — dùng ! gộp 0, undefined, null, "" thành một. Nếu một đơn cũ thiếu trường status thì vẫn hiện "Đơn hàng mới". Cách đúng: item.status === 0 ? .... Tốt hơn nữa: tách một object hằng const ORDER_STATUS = { 0: "Đơn hàng mới", 1: "Đã xác nhận", ... } rồi tra ORDER_STATUS[item.status] — thay cho chuỗi ternary lồng 5 tầng đang bị copy ở 2 nơi với 2 cách viết khác nhau.

9.4. CartDetailPage.js — đổi trạng thái đơn

yotea-fe/src/pages/admin/cart/CartDetailPage.js:34-57

  const handleUpdateStt = (status) => {
    Swal.fire({
      title: "Bạn có chắc chắn muốn cập nhật trạng thái đơn hàng?",
      text: "Bạn không thể hoàn tác sau khi cập nhật!",
      icon: "warning",
      showCancelButton: true,
      confirmButtonColor: "#3085d6",
      cancelButtonColor: "#d33",
      confirmButtonText: "Yes!",
    }).then(async (result) => {
      if (result.isConfirmed) {
        setLoading(true);

        const { data } = await update({
          ...order,
          status,
        });

        Swal.fire("Thành công!", "Đơn hàng đã được cập nhật.", "success");
        setOrder(data);
        setLoading(false);
      }
    });
  };

Đường đi đầy đủ khi admin bấm "Xác nhận ĐH":

Bấm nút  →  handleUpdateStt(1)
         →  Swal.fire xác nhận
         →  update({ ...order, status: 1 })          [api/order.js:33-40]
            └─ PUT /api/orders/<orderId>/<adminId>   + header Authorization: Bearer <token>
               └─ routes/order.js:11  requireSignin → isAuth → update
                  └─ controllers/order.js update()
                     └─ Order.findOneAndUpdate(...)  → MongoDB collection orders
         ←  res.json(orderMoi)
         →  setOrder(data)  →  React vẽ lại nhãn + đổi nút sang bước kế tiếp

⚠️ Chỗ này dự án làm chưa chuẩn — 4 vấn đề trong 20 dòng:

  1. update({ ...order, status }) gửi nguyên cả object đơn hàng lên (kèm _id, createdAt, userId, totalPrice…). Chỉ cần đổi status mà gửi cả cục → nếu client sửa totalPrice trước khi gửi thì server ghi đè luôn. Cách đúng: chỉ gửi { status }, hoặc dùng PATCH.
  2. await update(...) không có try/catch. API lỗi → promise reject → setLoading(false) ở dòng 54 không bao giờ chạy → vòng xoay loading (<Loading active={loading} />, :267) quay vĩnh viễn, phải F5.
  3. Swal.fire("Thành công!") ở dòng 52 chạy trước khi kiểm tra kết quả — trên lý thuyết là sau await nên OK, nhưng nếu API 4xx thì cả dòng này lẫn 2 dòng sau đều bị bỏ qua và không có thông báo lỗi nào.
  4. Không có transition guard ở server: UI chỉ ẩn nút, còn API cho phép nhảy từ 0 thẳng lên 3, hoặc từ 4 (đã huỷ) về 1. Ai gọi thẳng API là làm được. Cách đúng: kiểm tra chuyển trạng thái hợp lệ trong controller.

⚠️ CartDetailPage.js:18 const { user } = useSelector(selectAuth); — khai báo rồi không dùng ở đâu → biến chết.

🔒 Ghi chú bảo mật: routes/order.js:8-12 cho thấy POST /orders, GET /orders, GET /orders/:id, DELETE /orders/:id hoàn toàn không có middleware. Ai cũng gọi được GET http://localhost:8080/api/orders để lấy toàn bộ tên, số điện thoại, email, địa chỉ khách hàng, và DELETE để xoá đơn bất kỳ. Chỉ PUT là có requireSignin, isAuth. Chi tiết ở Bài 33.


10. Kiểm duyệt bình luận — AdminCommentList.js

yotea-fe/src/components/admin/AdminCommentList.js:11-20

  useEffect(() => {
    const getComment = async () => {
      const { data } = await get(id);
      onSetTotal(data.length);

      const { data: listComment } = await get(id, start, limit);
      setComments(listComment);
    };
    getComment();
  }, [start]);

Hàm xoá, yotea-fe/src/components/admin/AdminCommentList.js:22-42:

  const handleRemoveCmt = async (id) => {
    Swal.fire({
      title: "Bạn có chắc chắn muốn xóa không?",
      text: "Bạn không thể hoàn tác sau khi xóa!",
      icon: "warning",
      showCancelButton: true,
      confirmButtonColor: "#3085d6",
      cancelButtonColor: "#d33",
      confirmButtonText: "Yes, delete it!",
    }).then((result) => {
      if (result.isConfirmed) {
        remove(id)
          .then(() => {
            Swal.fire("Thành công!", "Đã xóa thành công.", "success");
          })
          .then(() =>
            setComments((prev) => prev?.filter((item) => item._id !== id))
          );
      }
    });
  };

Bảng hiển thị 5 cột: STT, Người bình luận (avatar + fullName + email), Nội dung, Thời gian, nút Delete.

☠️ MÀN NÀY LÀ CODE CHẾT. Không file nào import AdminCommentList. Không có route /admin/comment. Nghĩa là chức năng kiểm duyệt bình luận chưa tồn tại trong ứng dụng — bạn không có cách nào bấm vào nó từ giao diện.

⚠️ Dấu hiệu dở dang rõ nhất: :9 const { id } = useParams(); rồi :13 get(id) — get của api/comment.js:15 nhận productId. Tức component này được thiết kế để nằm trong một route có :id là id sản phẩm, kiểu /admin/product/:id/comments. Route đó chưa bao giờ được tạo.

⚠️ :96 item.userId.avatar không optional chaining → nếu một bình luận mồ côi (user đã bị xoá) thì crash cả bảng. Cách đúng: item.userId?.avatar.

⚠️ remove(id) không .catch(); và .then(...).then(...) xâu chuỗi kiểu này khiến việc lọc mảng chạy kể cả khi người dùng bấm OK trên hộp thoại "Thành công" — trông thì đúng, nhưng nếu API lỗi thì cả hai .then đều bị bỏ qua và hộp thoại thành công cũng không hiện → im lặng tuyệt đối.


11. Quản lý liên hệ

11.1. ContactListPage.js — lấy tổng từ Redux

yotea-fe/src/pages/admin/contact/ContactListPage.js:7-17

const ContactListPage = () => {
  const totalItem = useSelector(selectTotalContact);

  const { page } = useParams();

  const limit = 10;
  const totalPage = Math.ceil(totalItem / limit);
  let currentPage = Number(page) || 1;
  currentPage =
    currentPage < 1 ? 1 : currentPage > totalPage ? totalPage : currentPage;
  const start = (currentPage - 1) * limit > 0 ? (currentPage - 1) * limit : 0;

Đây là cách thứ ba để lấy tổng số bản ghi trong cùng một dự án:

Cách Ai dùng Cơ chế
A ProductListPage RTK Query useGetProductsQuery({}) rồi .length
B CartListPage con gọi callback onSetTotal set state cho cha
C ContactListPage, NewsListPage, UserListPage thunk lưu totalContact vào Redux, cha useSelector

Cách C nằm ở yotea-fe/src/redux/contactSlice.js:9-18:

export const getContacts = createAsyncThunk(
  "contact/getContacts",
  async ({ start, limit }) => {
    const { data } = await getAll();
    const totalContact = data.length;

    const { data: dataContact } = await getAll(start, limit);
    return { totalContact, dataContact };
  }
);

Vẫn là 2 request, chỉ khác chỗ chứa kết quả.

11.2. ContactDetailPage.js — form chỉ để xem

yotea-fe/src/pages/admin/contact/ContactDetailPage.js:11-17

  useEffect(() => {
    const getContact = async () => {
      const { data } = await get(id);
      setContact({ ...data, createdAt: formatDate(data.createdAt) });
    };
    getContact();
  }, []);

Mọi ô đều disabled + defaultValue, ví dụ yotea-fe/src/pages/admin/contact/ContactDetailPage.js:54-59:

                  <input
                    type="text"
                    disabled
                    className=/* ...class Tailwind... */
                    defaultValue={contact?.name}
                  />

⚠️ BUG NẶNG — form này luôn rỗng. defaultValue trên input uncontrolled chỉ được React áp dụng ở lần render đầu tiên. Lúc đó contact còn là undefined (dữ liệu về sau, bất đồng bộ). Khi setContact chạy và component render lại, React không đụng vào value của DOM nữa → mọi ô vẫn trắng.

Cách đúng (3 lựa chọn):

  1. Dùng controlled: value={contact?.name ?? ""} readOnly.
  2. Ép remount cả form: thêm key={contact?._id} lên thẻ cha.
  3. Chỉ hiện khi có dữ liệu: {contact && ( <form>…</form> )}.

⚠️ :100 defaultValue={contact?.store.name} — optional chaining nửa vời. Nếu contact có nhưng store chưa được populate thì undefined.name → TypeError, trắng màn. Phải là contact?.store?.name. Lỗi y hệt ở ContactList.js:113 — {item.store.name} không có ?. nào, đủ để làm sập cả bảng danh sách.

⚠️ :110-114 ô "Ngày gửi" có id="a" (id vô nghĩa) và quên disabled trong khi 5 ô còn lại đều có → admin gõ được vào ô này (dù không lưu đi đâu).

⚠️ :40 bọc tất cả trong <form action="" method="POST"> dù không submit gì. Bấm Enter trong ô "Ngày gửi" sẽ khiến trình duyệt reload trang.


12. ⚠️ Dự án trộn 3 phong cách lấy dữ liệu trong cùng khu admin

Đây là điều gây bối rối nhất khi bạn đọc code khu /admin lần đầu:

Màn Cách lấy dữ liệu Nơi khai báo
Sản phẩm (list/add/edit/delete) RTK Query useGetProductsQuery, useAddProductMutation, useUpdateProductMutation, useDeleteProductMutation api/product.js:96-147
Danh mục SP, Tin tức, Danh mục tin, Slider, User, Liên hệ createAsyncThunk + slice redux/*.js
Đơn hàng, Chi tiết liên hệ, Bình luận gọi axios trực tiếp trong useEffect + useState api/order.js, api/contact.js, api/comment.js

⚠️ Chỗ này dự án làm chưa chuẩn: ba phong cách cùng tồn tại. Hệ quả thực tế:

  • Xoá một sản phẩm → RTK Query invalidatesTags: ["Product"] tự nạp lại bảng. ✅
  • Xoá một liên hệ → slice tự lọc mảng trong reducer. ✅ nhưng phải viết tay từng case.
  • Xoá một bình luận → tự setComments(prev => prev.filter(...)). ✅ nhưng nếu có 2 component cùng hiển thị bình luận thì component kia không biết và vẫn hiện dòng đã xoá.

Cách đúng: chọn một phong cách cho cả dự án. Với dữ liệu server thuần CRUD như thế này, RTK Query là lựa chọn gọn nhất (xem lại Bài 22).

⚠️ Và cụ thể ở ProductListPage.js:7: useGetProductsQuery({}) — truyền object rỗng, không có start/limit. Endpoint getProducts (api/product.js:104-106) có limit = 0 mặc định và if (limit) url += ... → điều kiện false → URL không kèm phân trang → tải hết. Trang này cố ý làm vậy để đếm tổng, nhưng nó vô tình dạy sai: nhiều bạn copy dòng đó sang chỗ khác rồi thắc mắc "sao phân trang không ăn".


13. 🛠️ Tự tay làm — 3 màn quản trị Topping

Mục tiêu phần này: cuối phần bạn vào được http://localhost:3000/admin/topping, thấy bảng topping có phân trang, bấm Thêm topping để tạo mới kèm ảnh upload lên tài khoản Cloudinary của chính bạn, bấm Edit để sửa mà không cần chọn lại ảnh, bấm Delete để xoá có xác nhận. Và có một mục Toppings trong sidebar.

Đây là bài tổng duyệt: bạn sẽ dùng lại đúng khuôn mẫu ở mục 4 — nhưng làm đúng ở những chỗ dự án làm sai.

Những gì bạn đã có từ các bài trước

Từ bài Bạn đã có
05, 07, 08, 09, 12, 13 yotea-be/src/models/topping.js, routes/topping.js, controllers/topping.js — CRUD + slug + bộ lọc + isAdmin + swagger
18 yotea-fe/src/api/topping.js — getAll / get / add / update / remove
20 yotea-fe/src/redux/toppingSlice.js
22 RTK Query trong api/topping.js
15, 25 Route /topping và ToppingPage, ToppingCard phía khách

Bài này giả định api/topping.js của bạn export những thứ sau. Nếu bạn đặt tên khác, đổi lại cho khớp:

// yotea-fe/src/api/topping.js — bạn đã viết ở bài 18 & 22
export const getAll = (start = 0, limit = 0) => { ... };   // GET /api/toppings
export const get    = (slug) => { ... };                   // GET /api/toppings/:slug
export const {
  useGetToppingsQuery,
  useAddToppingMutation,
  useUpdateToppingMutation,
  useDeleteToppingMutation,
} = toppingApi;

Và model Topping có các trường: name, price, image, slug, status, timestamps. Nếu model của bạn chưa có image hoặc status, hãy thêm vào trước khi làm tiếp — bài này cần cả hai.

Bước 0 — Tạo helper upload của riêng bạn

Bạn đã có cloud name + preset ở mục 7.5. Tạo file MỚI (không sửa utils/index.js):

// yotea-fe/src/utils/cloudinary.js  ← file MỚI, bạn tự tạo
import axios from "axios";

// TODO: thay 2 hằng số này bằng của TÀI KHOẢN CLOUDINARY CỦA BẠN
const CLOUD_NAME = "dq7xk2abc";        // Dashboard → Cloud name
const UPLOAD_PRESET = "yotea_unsigned"; // Settings → Upload → Upload presets (Unsigned)

export const uploadImage = async (file) => {
  const formData = new FormData();
  formData.append("file", file);
  formData.append("upload_preset", UPLOAD_PRESET);

  const { data } = await axios.post(
    `https://api.cloudinary.com/v1_1/${CLOUD_NAME}/image/upload`,
    formData
  );

  // KHÁC dự án: dùng secure_url (https) thay vì url (http)
  return data.secure_url;
};

💡 Ba khác biệt so với utils/index.js: (1) cloud name/preset là của bạn, (2) đưa lên hằng số có tên rõ ràng thay vì nhét thẳng vào chuỗi, (3) trả secure_url. Ở Bài 34 ta sẽ đẩy nốt 2 hằng số này ra file .env.

Bước 1 — ToppingListPage.js

// yotea-fe/src/pages/admin/topping/ToppingListPage.js  ← file MỚI, bạn tự tạo
import { Link, useParams } from "react-router-dom";
import Swal from "sweetalert2";
import { toast } from "react-toastify";
import {
  useGetToppingsQuery,
  useDeleteToppingMutation,
} from "../../../api/topping";
import AdminPagination from "../../../components/admin/AdminPagination";
import { formatCurrency } from "../../../utils";

const ToppingListPage = () => {
  const { page } = useParams();

  // Lấy TẤT CẢ để đếm tổng (giống dự án) — biết là chưa tối ưu, xem ghi chú bên dưới
  const { data: allToppings } = useGetToppingsQuery({});
  const totalItem = allToppings?.length || 0;

  const limit = 10;
  // KHÁC dự án: Math.max(1, ...) để không bao giờ ra totalPage = 0
  const totalPage = Math.max(1, Math.ceil(totalItem / limit));
  let currentPage = Number(page) || 1;
  currentPage =
    currentPage < 1 ? 1 : currentPage > totalPage ? totalPage : currentPage;
  const start = (currentPage - 1) * limit;

  const { data: toppings, isLoading } = useGetToppingsQuery({ start, limit });
  const [deleteTopping] = useDeleteToppingMutation();

  const handleRemove = (id) => {
    Swal.fire({
      title: "Bạn có chắc chắn muốn xóa topping này?",
      text: "Bạn không thể hoàn tác sau khi xóa!",
      icon: "warning",
      showCancelButton: true,
      confirmButtonColor: "#3085d6",
      cancelButtonColor: "#d33",
      confirmButtonText: "Xóa",
      cancelButtonText: "Hủy",
    }).then((result) => {
      if (result.isConfirmed) {
        deleteTopping(id)
          .unwrap()
          .then(() => Swal.fire("Thành công!", "Đã xóa topping.", "success"))
          // KHÁC dự án: CÓ .catch() nên xoá lỗi là biết ngay
          .catch(() => toast.error("Xóa thất bại, vui lòng thử lại"));
      }
    });
  };

  return (
    <>
      <header className="z-10 fixed top-14 left-0 md:left-60 right-0 px-4 py-1.5 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.1)] flex items-center justify-between">
        <div className="flex items-center text-sm text-gray-600">
          <h5 className="pr-5">Toppings</h5>
          <span>DS topping</span>
        </div>
        <Link to="/admin/topping/add">
          <button
            type="button"
            className="inline-flex items-center px-2 py-1 rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
          >
            Thêm topping
          </button>
        </Link>
      </header>

      <div className="p-6 mt-24 overflow-hidden">
        <div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
          <table className="min-w-full divide-y divide-gray-200">
            <thead className="bg-gray-50">
              <tr>
                {["STT", "Ảnh", "Tên topping", "Giá", "Trạng thái", "Actions"].map((h) => (
                  <th key={h} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                    {h}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody className="bg-white divide-y divide-gray-200">
              {isLoading && (
                <tr>
                  <td colSpan={6} className="px-6 py-4 text-sm text-gray-500">
                    Đang tải...
                  </td>
                </tr>
              )}

              {toppings?.map((item, index) => (
                // KHÁC dự án: key là _id, và STT không mutate index
                <tr key={item._id}>
                  <td className="px-6 py-4 text-sm text-gray-500">
                    {index + 1 + start}
                  </td>
                  <td className="px-6 py-4">
                    <img
                      src={item.image}
                      alt={item.name}
                      className="h-10 w-10 rounded-full object-cover"
                    />
                  </td>
                  <td className="px-6 py-4 text-sm font-medium text-gray-900">
                    {item.name}
                  </td>
                  <td className="px-6 py-4 text-sm text-gray-500">
                    {formatCurrency(item.price || 0)}
                  </td>
                  <td className="px-6 py-4">
                    <span
                      className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
                        item.status
                          ? "bg-green-100 text-green-800"
                          : "bg-red-100 text-red-800"
                      }`}
                    >
                      {item.status ? "Hiển thị" : "Ẩn"}
                    </span>
                  </td>
                  <td className="px-6 py-4 text-sm font-medium">
                    <Link
                      to={`/admin/topping/${item.slug}/edit`}
                      className="h-8 inline-flex items-center px-3 rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700"
                    >
                      Edit
                    </Link>
                    <button
                      onClick={() => handleRemove(item._id)}
                      className="h-8 inline-flex items-center px-3 ml-3 rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700"
                    >
                      Delete
                    </button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>

          <AdminPagination
            page={currentPage}
            totalPage={totalPage}
            url="topping"
          />
        </div>
      </div>
    </>
  );
};

export default ToppingListPage;

💡 Chú ý dòng to={`/admin/topping/${item.slug}/edit`} — nút Edit sinh slug. Ở Bước 5 ta sẽ khai báo route là :slug/edit (không phải :id/edit như dự án) để tránh đúng cái bug ở mục 6.3.

Bước 2 — AddToppingPage.js

// yotea-fe/src/pages/admin/topping/AddToppingPage.js  ← file MỚI, bạn tự tạo
import { useState } from "react";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { Link } from "react-router-dom";
import { toast } from "react-toastify";
import { useAddToppingMutation } from "../../../api/topping";
import { uploadImage } from "../../../utils/cloudinary";

const schema = yup.object().shape({
  name: yup.string().required("Vui lòng nhập tên topping"),
  price: yup
    .number()
    .typeError("Giá phải là số")            // KHÁC dự án: chặn được chữ và ô rỗng
    .min(0, "Giá không được âm")
    .required("Vui lòng nhập giá topping"),
  image: yup
    .mixed()
    .test("require", "Vui lòng chọn ảnh topping", (value) => value?.length > 0),
  status: yup
    .number()
    .typeError("Vui lòng chọn trạng thái")  // KHÁC dự án: chặn được option rỗng
    .required("Vui lòng chọn trạng thái"),
});

const AddToppingPage = () => {
  const [preview, setPreview] = useState("");
  const [loading, setLoading] = useState(false);
  const [addTopping] = useAddToppingMutation();

  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm({
    resolver: yupResolver(schema),
    defaultValues: { status: 1 },   // KHÁC dự án: defaultValues thay vì defaultValue trên <select>
  });

  // tách onChange của RHF ra để không bị ghi đè
  const { onChange: rhfOnChangeImage, ...imageField } = register("image");

  const handlePreview = (e) => {
    const file = e.target.files?.[0];
    if (!file) return;
    setPreview((old) => {
      if (old) URL.revokeObjectURL(old);   // KHÁC dự án: dọn blob cũ, không rò rỉ bộ nhớ
      return URL.createObjectURL(file);
    });
  };

  const onSubmit = async (data) => {
    setLoading(true);
    try {
      const image = await uploadImage(data.image[0]);   // ① lên Cloudinary CỦA BẠN
      await addTopping({ ...data, image }).unwrap();    // ② POST về backend Yotea
      toast.success("Thêm topping thành công");
      reset();
      setPreview("");
    } catch (error) {
      // KHÁC dự án: await + try/catch nên MỌI lỗi đều rơi vào đây
      toast.error(
        error?.data?.message || "Có lỗi xảy ra, vui lòng thử lại"
      );
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <header className="z-10 fixed top-14 left-0 md:left-60 right-0 px-4 py-1.5 bg-white shadow flex items-center justify-between">
        <div className="flex items-center text-sm text-gray-600">
          <h5 className="pr-5">Toppings</h5>
          <span>Thêm topping</span>
        </div>
        <Link to="/admin/topping">
          <button className="px-2 py-1 rounded-md text-sm text-white bg-indigo-600">
            DS topping
          </button>
        </Link>
      </header>

      <div className="p-6 mt-24">
        <form onSubmit={handleSubmit(onSubmit)}>
          <div className="shadow sm:rounded-md bg-white p-6 grid grid-cols-6 gap-6">
            <div className="col-span-6">
              <label className="block text-sm font-medium text-gray-700">Tên topping</label>
              <input
                type="text"
                {...register("name")}
                placeholder="VD: Trân châu đen"
                className="py-2 px-3 mt-1 border block w-full rounded-md border-gray-300"
              />
              <div className="text-sm mt-0.5 text-red-500">{errors.name?.message}</div>
            </div>

            <div className="col-span-6 md:col-span-3">
              <label className="block text-sm font-medium text-gray-700">Giá (VNĐ)</label>
              <input
                type="number"
                {...register("price")}
                placeholder="VD: 8000"
                className="py-2 px-3 mt-1 border block w-full rounded-md border-gray-300"
              />
              <div className="text-sm mt-0.5 text-red-500">{errors.price?.message}</div>
            </div>

            <div className="col-span-6 md:col-span-3">
              <label className="block text-sm font-medium text-gray-700">Trạng thái</label>
              <select
                {...register("status")}
                className="mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md"
              >
                <option value="">-- Chọn trạng thái --</option>
                <option value={0}>Ẩn</option>
                <option value={1}>Hiển thị</option>
              </select>
              <div className="text-sm mt-0.5 text-red-500">{errors.status?.message}</div>
            </div>

            <div className="col-span-6 md:col-span-3">
              <label className="block text-sm font-medium text-gray-700">Xem trước ảnh</label>
              <img
                src={preview || "https://placehold.co/400x300?text=Topping"}
                alt="Xem truoc anh topping"
                className="mt-1 h-48 w-full object-cover rounded-md border"
              />
            </div>

            <div className="col-span-6">
              <label className="block text-sm font-medium text-gray-700">Ảnh topping</label>
              <input
                type="file"
                accept="image/*"                       {/* KHÁC dự án: chỉ cho chọn ảnh */}
                {...imageField}
                onChange={(e) => {
                  rhfOnChangeImage(e);   // để RHF/yup biết đã có file
                  handlePreview(e);      // rồi mới hiện preview
                }}
                className="mt-1 block w-full text-sm"
              />
              <div className="text-sm mt-0.5 text-red-500">{errors.image?.message}</div>
            </div>

            <div className="col-span-6 text-right">
              <button
                type="submit"
                disabled={loading}       {/* KHÁC dự án: chặn double-click */}
                className="py-2 px-4 rounded-md text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50"
              >
                {loading ? "Đang lưu..." : "Thêm topping"}
              </button>
            </div>
          </div>
        </form>
      </div>
    </>
  );
};

export default AddToppingPage;

Bước 3 — EditToppingPage.js

// yotea-fe/src/pages/admin/topping/EditToppingPage.js  ← file MỚI, bạn tự tạo
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { get, useUpdateToppingMutation } from "../../../api/topping";
import { uploadImage } from "../../../utils/cloudinary";

// GIỐNG AddToppingPage nhưng BỎ trường `image`
// → cho phép sửa mà không cần chọn lại ảnh
const schema = yup.object().shape({
  name: yup.string().required("Vui lòng nhập tên topping"),
  price: yup
    .number()
    .typeError("Giá phải là số")
    .min(0, "Giá không được âm")
    .required("Vui lòng nhập giá topping"),
  status: yup
    .number()
    .typeError("Vui lòng chọn trạng thái")
    .required("Vui lòng chọn trạng thái"),
});

const EditToppingPage = () => {
  const { slug } = useParams();          // khớp với route ":slug/edit" ở Bước 5
  const navigate = useNavigate();
  const [preview, setPreview] = useState("");
  const [loading, setLoading] = useState(true);
  const [updateTopping] = useUpdateToppingMutation();

  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm({ resolver: yupResolver(schema) });

  const { onChange: rhfOnChangeImage, ...imageField } = register("image");

  useEffect(() => {
    const getTopping = async () => {
      try {
        const { data } = await get(slug);
        setPreview(data.image);
        reset(data);        // ← nạp toàn bộ dữ liệu cũ vào form
      } catch (error) {
        toast.error("Không tải được topping này");
      } finally {
        setLoading(false);
      }
    };
    getTopping();
  }, [slug, reset]);        // KHÁC dự án: khai đủ deps, không để [] rỗng

  const handlePreview = (e) => {
    const file = e.target.files?.[0];
    if (!file) return;
    setPreview((old) => {
      if (old?.startsWith("blob:")) URL.revokeObjectURL(old);
      return URL.createObjectURL(file);
    });
  };

  const onSubmit = async (data) => {
    setLoading(true);
    try {
      // ĐÂY LÀ TRÁI TIM CỦA TRANG SỬA:
      // - không chọn ảnh mới → data.image là CHUỖI url cũ → bỏ qua upload
      // - có chọn ảnh mới    → data.image là FileList     → upload lấy url mới
      if (typeof data.image === "object" && data.image?.length) {
        data.image = await uploadImage(data.image[0]);
      }

      await updateTopping(data).unwrap();
      toast.success("Cập nhật topping thành công");
      navigate("/admin/topping");
    } catch (error) {
      toast.error(error?.data?.message || "Có lỗi xảy ra, vui lòng thử lại");
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <header className="z-10 fixed top-14 left-0 md:left-60 right-0 px-4 py-1.5 bg-white shadow flex items-center justify-between">
        <div className="flex items-center text-sm text-gray-600">
          <h5 className="pr-5">Toppings</h5>
          <span>Cập nhật topping</span>   {/* nhớ: KHÔNG copy nhầm chữ "Thêm" như dự án */}
        </div>
        <Link to="/admin/topping">
          <button className="px-2 py-1 rounded-md text-sm text-white bg-indigo-600">
            DS topping
          </button>
        </Link>
      </header>

      <div className="p-6 mt-24">
        {/* phần form giống hệt AddToppingPage, chỉ đổi chữ nút thành "Cập nhật topping"
            và ảnh preview lấy từ state `preview` đã được set bằng data.image */}
        <form onSubmit={handleSubmit(onSubmit)}>
          {/* ...copy y nguyên khối grid 6 cột từ AddToppingPage... */}
          <div className="col-span-6 text-right">
            <button
              type="submit"
              disabled={loading}
              className="py-2 px-4 rounded-md text-white bg-indigo-600 disabled:opacity-50"
            >
              {loading ? "Đang lưu..." : "Cập nhật topping"}
            </button>
          </div>
        </form>
      </div>
    </>
  );
};

export default EditToppingPage;

Bước 4 — Thêm route vào App.js

Mở yotea-fe/src/App.js. Thêm 3 dòng import cạnh các import admin khác (khoảng dòng 56):

// yotea-fe/src/App.js — bạn tự thêm
import ToppingListPage from "./pages/admin/topping/ToppingListPage";
import AddToppingPage from "./pages/admin/topping/AddToppingPage";
import EditToppingPage from "./pages/admin/topping/EditToppingPage";

Rồi thêm một phần tử vào mảng children của route /admin — đặt ngay sau khối product (kết thúc ở dòng 279):

// yotea-fe/src/App.js — bạn tự thêm, đặt trong children của "/admin"
        {
          path: "topping",
          children: [
            {
              path: "",
              element: <ToppingListPage />,
            },
            {
              path: "page/:page",
              element: <ToppingListPage />,
            },
            {
              path: "add",
              element: <AddToppingPage />,
            },
            {
              // KHÁC dự án: dùng :slug cho khớp với useParams().slug
              path: ":slug/edit",
              element: <EditToppingPage />,
            },
          ],
        },

Bước 5 — Thêm mục menu vào AdminLayout.js

Mở yotea-fe/src/pages/layouts/AdminLayout.js. Thêm faCookieBite vào khối import icon (dòng 2-11):

// yotea-fe/src/pages/layouts/AdminLayout.js — bạn tự thêm vào danh sách import sẵn có
import {
  faCookieBite,   // ← thêm dòng này
  faComment,
  faNewspaper,
  ...
} from "@fortawesome/free-solid-svg-icons";

Rồi chèn <NavLink> mới ngay sau mục Categories Products (kết thúc ở dòng 123):

// yotea-fe/src/pages/layouts/AdminLayout.js — bạn tự thêm
            <NavLink
              to="/admin/topping"
              className="sidebar__item flex items-center justify-between px-4 py-3 transition cursor-pointer group hover:bg-gray-800 hover:text-gray-200"
            >
              <div className="flex items-center">
                <div className="shrink-0 w-5 h-5 mr-2 text-gray-300 transition group-hover:text-gray-300">
                  <FontAwesomeIcon icon={faCookieBite} />
                </div>
                <span>Toppings</span>
              </div>
            </NavLink>

14. ✅ Kiểm chứng kết quả

Chuẩn bị:

# terminal 1 — đứng tại thư mục yotea-be
npm start

# terminal 2 — đứng tại thư mục yotea-fe
npm start

Đăng nhập bằng tài khoản admin (role = 1, active = 1).

Checklist 8 điểm — làm lần lượt:

# Việc Kết quả PHẢI thấy
1 Mở http://localhost:3000/admin Sidebar tối màu 10 mục (đã có Toppings), nội dung chỉ có chữ Dashboard
2 Bấm Toppings Bảng topping; nếu chưa có dữ liệu thì bảng trống, không lỗi đỏ trong Console
3 Bấm Thêm topping, bỏ trống hết, bấm Submit 4 dòng chữ đỏ hiện dưới 4 ô. Không có request nào trong tab Network
4 Điền Trân châu đen, giá 8000, chọn ảnh, trạng thái Hiển thị, Submit Tab Network: request 1 tới api.cloudinary.com trả 200 kèm JSON có secure_url; request 2 POST /api/toppings/<userId> trả 200. Toast xanh "Thêm topping thành công", form trắng trở lại
5 Quay lại danh sách Dòng mới xuất hiện với ảnh đã upload hiện đúng
6 Mở Cloudinary → Media Library → thư mục yotea Thấy đúng tấm ảnh vừa upload
7 Bấm Edit, chỉ sửa tên thành Trân châu đen size L, không chọn ảnh, Submit Tab Network: chỉ có 1 request PUT /api/toppings/... — không có request Cloudinary. Ảnh trong bảng giữ nguyên
8 Bấm Delete → chọn "Xóa" Hộp SweetAlert2 xác nhận → dòng biến mất khỏi bảng ngay lập tức (RTK Query invalidatesTags tự nạp lại)

Kiểm tra bằng API (không qua giao diện):

curl "http://localhost:8080/api/toppings?_sort=createdAt&_order=desc&_start=0&_limit=10"

Phải nhận về mảng, trong đó có bản ghi của bạn:

[
  {
    "_id": "66be1a2f5c9d3e0012ab34cd",
    "name": "Trân châu đen size L",
    "price": 8000,
    "image": "https://res.cloudinary.com/dq7xk2abc/image/upload/v1723.../yotea/abc123.jpg",
    "slug": "Tran-chau-den-size-L",
    "status": 1,
    "createdAt": "2026-08-15T09:12:33.123Z",
    "updatedAt": "2026-08-15T09:20:11.456Z"
  }
]

✅ Nhìn vào image: phải bắt đầu bằng https:// và chứa cloud name của bạn, không phải levantuan. Nếu vẫn thấy levantuan nghĩa là bạn đang gọi nhầm uploadFile cũ.


15. 🐞 Lỗi thường gặp

Thông báo lỗi Nguyên nhân Cách sửa
{"error":{"message":"Upload preset not found"}} (400 từ Cloudinary) Sai tên preset, hoặc preset thuộc cloud name khác Kiểm tra lại Settings → Upload → Upload presets; tên preset phân biệt hoa/thường
Upload preset must be whitelisted for unsigned uploads Preset đang để Signed Sửa preset → Signing Mode → Unsigned → Save
Bấm "Thêm" mà không có gì xảy ra, Console báo Unhandled promise rejection try/catch bắt hụt vì thiếu await trước .unwrap() Dùng await addTopping(...).unwrap() như Bước 2
Cannot read properties of undefined (reading 'length') trong resolver yup.mixed().test(..., v => v.length) gặp undefined Đổi thành (v) => v?.length > 0
Trang Edit hiện form trống trơn Tên tham số route không khớp useParams() (bug kinh điển của dự án) Route phải là :slug/edit nếu code đọc const { slug } = useParams()
<select> không tự chọn đúng danh mục cũ reset() nạp cả object đã populate thay vì _id reset({ ...data, categoryId: data.categoryId?._id })
Ảnh vỡ sau khi deploy lên https Backend lưu URL http:// (data.url) → mixed content bị chặn Đổi sang data.secure_url; chạy script cập nhật các bản ghi cũ
401 Unauthorized khi POST/PUT/DELETE topping Thiếu header Authorization, hoặc token hết hạn, hoặc user không phải admin Đăng xuất/đăng nhập lại; kiểm tra role === 1 trong DB
Bấm Profile trong menu avatar → trang trắng Link chết /admin/profile (mục 2.4) Thêm route hoặc bỏ mục menu
TypeError: Cannot read properties of undefined (reading 'name') ở màn Liên hệ item.store.name thiếu ?. (mục 11.2) item.store?.name
Bảng đơn hàng trống nhưng API có dữ liệu getAll() của api/order.js sắp xếp theo createdAt; đơn cũ thiếu trường này Kiểm tra timestamps: true trong models/order.js
Sửa xong bảng không cập nhật Dùng thunk mà quên viết case trong extraReducers; hoặc RTK Query thiếu invalidatesTags Thêm invalidatesTags: ["Topping"] vào mutation

16. 📝 Bài tập

Bài 1. Trang ToppingListPage hiện đang gọi API hai lần mỗi lần vào trang (một lần đếm tổng, một lần lấy trang), giống hệt anti-pattern của dự án. Hãy sửa backend để GET /api/toppings trả về tổng số bản ghi trong header X-Total-Count, rồi sửa frontend để chỉ còn một request.

<details> <summary>💡 Xem gợi ý & lời giải</summary>

Backend — trong yotea-be/src/controllers/topping.js, hàm list(), thêm 2 dòng trước khi trả về (đoạn này bạn tự viết thêm, dự án chưa có):

// yotea-be/src/controllers/topping.js — hàm list()
const total = await Topping.countDocuments(filter);   // đếm theo ĐÚNG bộ lọc, chưa phân trang
res.set("Access-Control-Expose-Headers", "X-Total-Count"); // bắt buộc, xem giải thích dưới
res.set("X-Total-Count", total);
res.json(toppings);

💡 Vì sao cần Access-Control-Expose-Headers? Đây là request cross-origin (3000 → 8080). Mặc định trình duyệt chỉ cho JavaScript đọc 7 header "an toàn"; header tự chế như X-Total-Count bị giấu đi trừ khi server khai báo cho phép lộ.

Frontend — RTK Query cho phép biến đổi response bằng transformResponse:

// yotea-fe/src/api/topping.js — bạn sửa endpoint getToppings
    getToppings: builder.query({
      query: ({ start = 0, limit = 0 }) => {
        let url = `/toppings/?_sort=createdAt&_order=desc`;
        if (limit) url += `&_start=${start}&_limit=${limit}`;
        return url;
      },
      transformResponse: (response, meta) => ({
        items: response,
        total: Number(meta?.response?.headers.get("X-Total-Count")) || 0,
      }),
      providesTags: ["Topping"],
    }),

Rồi trong ToppingListPage, xoá hẳn lời gọi useGetToppingsQuery({}) và dùng:

const { data, isLoading } = useGetToppingsQuery({ start, limit });
const toppings = data?.items;
const totalItem = data?.total || 0;

⚠️ Có một cái bẫy: start được tính từ totalPage, mà totalPage lại đến từ request. Vòng lặp phụ thuộc này giải bằng cách: lần render đầu totalItem = 0 → currentPage = 1 → start = 0 → request trang 1 → có total → totalPage đúng → nếu URL yêu cầu trang 3 thì render lại với start = 20. Tức là vẫn 2 request khi vào thẳng trang 3, nhưng chỉ 1 request ở trang 1. Muốn triệt để 1 request thì đừng clamp currentPage theo totalPage, mà để backend tự trả mảng rỗng khi _start vượt quá — rồi hiện thông báo "Trang không tồn tại".

</details>

Bài 2. Dashboard.js đang là component rỗng. Hãy biến nó thành trang thống kê hiển thị 4 con số: Tổng đơn hàng, Doanh thu (chỉ tính đơn đã giao, status === 3), Tổng sản phẩm, Tổng khách hàng. Nêu rõ trong code những chỗ bạn biết là chưa tối ưu.

<details> <summary>💡 Xem gợi ý & lời giải</summary>

Toàn bộ file mới (đoạn này bạn tự viết thêm, dự án chưa có):

// yotea-fe/src/pages/admin/Dashboard.js — bạn viết đè lên 7 dòng cũ
import { useEffect, useState } from "react";
import { getAll as getOrders } from "../../api/order";
import { getAll as getProducts } from "../../api/product";
import { getAll as getUsers } from "../../api/user";
import { formatCurrency } from "../../utils";

const Card = ({ label, value }) => (
  <div className="bg-white rounded-lg shadow p-5">
    <p className="text-sm text-gray-500">{label}</p>
    <p className="text-2xl font-semibold text-gray-900 mt-1">{value}</p>
  </div>
);

const Dashboard = () => {
  const [stats, setStats] = useState({
    totalOrder: 0,
    revenue: 0,
    totalProduct: 0,
    totalUser: 0,
  });
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const load = async () => {
      try {
        // ⚠️ CHƯA TỐI ƯU: backend không có API thống kê, nên phải tải
        // TOÀN BỘ 3 bảng về trình duyệt rồi tự cộng. Với vài nghìn bản ghi
        // là chậm thấy rõ. Cách đúng: viết endpoint GET /api/stats dùng
        // aggregation pipeline của MongoDB ($match + $group + $sum).
        const [{ data: orders }, { data: products }, { data: users }] =
          await Promise.all([getOrders(), getProducts(), getUsers()]);

        const revenue = orders
          .filter((o) => o.status === 3)   // chỉ đơn ĐÃ GIAO mới tính là doanh thu
          .reduce(
            (sum, o) =>
              sum + Math.max(0, (o.totalPrice || 0) - (o.priceDecrease || 0)),
            0
          );

        setStats({
          totalOrder: orders.length,
          revenue,
          totalProduct: products.length,
          totalUser: users.length,
        });
      } catch (error) {
        console.error("Không tải được thống kê", error);
      } finally {
        setLoading(false);
      }
    };
    load();
  }, []);

  if (loading) return <div className="p-6 mt-24">Đang tải thống kê...</div>;

  return (
    <div className="p-6 mt-24 grid grid-cols-1 md:grid-cols-4 gap-5">
      <Card label="Tổng đơn hàng" value={stats.totalOrder} />
      <Card label="Doanh thu (đã giao)" value={formatCurrency(stats.revenue)} />
      <Card label="Tổng sản phẩm" value={stats.totalProduct} />
      <Card label="Tổng khách hàng" value={stats.totalUser} />
    </div>
  );
};

export default Dashboard;

Ba điểm cần nêu là chưa tối ưu:

  1. Tải toàn bộ 3 collection về client — nên thay bằng aggregation ở server.
  2. getUsers() trả về cả trường password đã băm (sự thật B.11) — càng không nên tải về.
  3. Promise.all sẽ hỏng cả 3 nếu 1 API lỗi; nên dùng Promise.allSettled để phần nào chạy được thì vẫn hiện.
</details>

Bài 3 (khó). Thay chuỗi ternary lồng 5 tầng của trạng thái đơn hàng bằng một cấu hình tập trung, rồi dùng nó ở cả hai nơi (OrderList.js và CartDetailPage.js) — đồng thời chặn việc chuyển trạng thái sai (ví dụ từ 4 về 1) ở phía server.

<details> <summary>💡 Xem gợi ý & lời giải</summary>

Phía frontend — tạo file cấu hình dùng chung (file mới, bạn tự tạo):

// yotea-fe/src/constants/orderStatus.js  ← file MỚI
export const ORDER_STATUS = {
  NEW: 0,
  CONFIRMED: 1,
  SHIPPING: 2,
  DELIVERED: 3,
  CANCELLED: 4,
};

export const ORDER_STATUS_LABEL = {
  0: "Đơn hàng mới",
  1: "Đã xác nhận",
  2: "Đang giao hàng",
  3: "Đã giao hàng",
  4: "Đã hủy",
};

// bước kế tiếp hợp lệ của mỗi trạng thái
export const NEXT_STATUS = {
  0: [1, 4],
  1: [2, 4],
  2: [3, 4],
  3: [],
  4: [],
};

export const getStatusLabel = (status) =>
  ORDER_STATUS_LABEL[status] ?? "Không xác định";

Trong OrderList.js thay cả khối ternary bằng {getStatusLabel(item.status)}. Trong CartDetailPage.js, các nút được sinh tự động:

{NEXT_STATUS[order.status]?.map((next) => (
  <button key={next} onClick={() => handleUpdateStt(next)} className="...">
    {next === 4 ? "Hủy ĐH" : `Chuyển sang: ${getStatusLabel(next)}`}
  </button>
))}

Phía server — chặn thật sự, vì client chỉ ẩn nút chứ không ngăn được ai gọi thẳng API:

// yotea-be/src/controllers/order.js — trong hàm update()
const NEXT_STATUS = { 0: [1, 4], 1: [2, 4], 2: [3, 4], 3: [], 4: [] };

export const update = async (req, res) => {
  try {
    const current = await Order.findById(req.params.id);
    if (!current) return res.status(404).json({ message: "Không tìm thấy đơn hàng" });

    const next = Number(req.body.status);
    if (next !== current.status && !NEXT_STATUS[current.status].includes(next)) {
      return res.status(400).json({
        message: `Không thể chuyển đơn từ trạng thái ${current.status} sang ${next}`,
      });
    }

    // chỉ nhận đúng trường status, KHÔNG nhét cả req.body vào
    const order = await Order.findByIdAndUpdate(
      req.params.id,
      { status: next },
      { new: true }
    );
    res.json(order);
  } catch (error) {
    res.status(400).json({ message: "Cập nhật đơn hàng thất bại", error });
  }
};

Điểm quan trọng nhất của lời giải: quy tắc nghiệp vụ phải nằm ở server. Ẩn nút chỉ là trải nghiệm người dùng, không phải bảo mật.

</details>

📌 Tóm tắt

  • Khu /admin gồm 8 nhóm màn hình + Dashboard, tất cả nằm trong <PrivateRouter page="admin"><AdminLayout /></PrivateRouter>; AdminLayout giữ sidebar/header cố định và nhét route con vào <Outlet />.
  • Dashboard.js chỉ có 7 dòng, không hiển thị số liệu nào. Mục menu Profile là link chết vì 2 trang pages/admin/profile/* chưa bao giờ được nối route.
  • Mọi màn quản trị đi theo đúng một khuôn mẫu: List (công thức phân trang limit = 10 → bảng → Swal xoá → AdminPagination) → Add (yup có image → uploadFile → POST → toast + reset()) → Edit (yup bỏ image → get() + reset(data) → chỉ upload khi typeof data.image === "object" && data.image.length → PUT → navigate).
  • Ảnh không đi qua backend Yotea. uploadFile() (utils/index.js:3-12) đẩy FormData thẳng lên Cloudinary bằng unsigned upload với cloud name levantuan và preset kkio3wiw — tài khoản của tác giả gốc, hardcode, có thể đã chết. Hãy tạo tài khoản riêng. Hàm còn trả data.url (http) thay vì data.secure_url (https).
  • AdminPagination và Pagination cùng thuật toán, khác lớp áo; khác biệt thật sự chỉ là AdminPagination tự chèn tiền tố /admin vào URL.
  • Đơn hàng có 5 trạng thái 0→4; admin đổi bằng handleUpdateStt(n) → PUT /api/orders/:id/:userId — nhưng server không kiểm tra chuyển trạng thái hợp lệ, và các route GET/DELETE /orders không có middleware bảo vệ.
  • Dự án trộn 3 phong cách lấy dữ liệu trong cùng khu admin (RTK Query / thunk / axios trần), và ProductListPage gọi useGetProductsQuery({}) không phân trang để đếm tổng → mỗi lần vào trang là 2 request, một trong đó tải toàn bộ bảng.

Từ khoá tra cứu thêm: cloudinary unsigned upload preset, FormData multipart/form-data, react-hook-form reset, yup schema validation, sweetalert2 confirm dialog, react-toastify, react-router Outlet, X-Total-Count pagination header, RTK Query invalidatesTags

➡️ Bài tiếp theo: 33 — Rà soát bảo mật: dự án đang sai ở đâu — bạn đã thấy GET /api/orders cho ai cũng đọc được toàn bộ số điện thoại khách hàng. Bài sau ta gom hết những lỗ hổng như thế lại một chỗ, xếp hạng mức độ nguy hiểm, và bạn sẽ tự tay khai thác vài lỗ để hiểu vì sao chúng nghiêm trọng.


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í