Bài 32 — Trang quản trị: CRUD, phân trang, upload ảnh Cloudinary Phần 1
Phần 5 · Từng chức năng, từ đầu đến cuối — Thời lượng ước tính: ~150 phút ⬅️ Bài trước: 31 — Tin tức, liên hệ, cửa hàng và slider trang chủ · Bài sau: 33 — Rà soát bảo mật: dự án đang sai ở đâu ➡️ 🏠 Mục lục
🎯 Sau bài này bạn sẽ
- Đọc hiểu được toàn bộ khu vực
/admin: 8 nhóm màn hình quản trị được dựng theo cùng một khuôn mẫu. - Mổ được
AdminLayout.js— sidebar, menu,<Outlet />— và biết vì sao mục Profile bấm vào là hỏng. - Nắm được khuôn mẫu chung: trang danh sách (gọi API → phân trang → sửa/xoá) → trang thêm (react-hook-form + yup + upload ảnh) → trang sửa (
reset()nạp dữ liệu cũ). - Tự tạo tài khoản Cloudinary miễn phí của riêng mình, tạo unsigned upload preset, và hiểu hàm
uploadFile()làm gì từng dòng. - Đọc được bảng trạng thái đơn hàng (
status0→4) và biết admin đổi trạng thái đơn bằng cách nào. - Tự viết trọn 3 màn quản trị Topping (danh sách / thêm / sửa) + route + mục menu — bài tổng duyệt trước đồ án cuối khoá.
📋 Cần chuẩn bị
- Đã hoàn thành Bài 31 — Tin tức, liên hệ, cửa hàng và slider.
- Nhớ lại Bài 20 —
createAsyncThunkvà Bài 22 — RTK Query, vì khu admin dùng cả hai. - Backend + frontend đang chạy (
npm startở cảyotea-be/vàyotea-fe/). - Một tài khoản admin (user có
role = 1,active = 1) để đăng nhập vào/admin. - Một trình duyệt có mạng — bài này sẽ đăng ký Cloudinary.
0. Sơ đồ luồng — "Thêm một sản phẩm" đi trọn các lớp
Đây là chức năng đại diện cho cả khu admin. Mọi màn "thêm" khác (tin tức, danh mục, slider, user) chỉ là bản sao của luồng này với tên field khác.
flowchart TD
A["<b>AddProductPage.js</b><br/>form 6 ô + input file"] -->|"handleSubmit(onSubmit)"| B["<b>yup schema</b><br/>name / price / description /<br/>categoryId / image / status"]
B -->|"hợp lệ"| C["<b>onSubmit(data)</b>"]
B -->|"lỗi"| B2["errors.xxx?.message<br/>hiện chữ đỏ dưới ô"]
C --> D["<b>uploadFile(data.image[0])</b><br/>yotea-fe/src/utils/index.js"]
D -->|"POST multipart/form-data"| E["<b>Cloudinary</b><br/>api.cloudinary.com/v1_1/<cloud>/image/upload"]
E -->|"{ url, secure_url, ... }"| D
D -->|"trả về data.url"| F["<b>addProduct(...)</b><br/>RTK Query mutation<br/>api/product.js"]
F -->|"POST /api/products/:userId<br/>+ Bearer token"| G["<b>routes/product.js</b><br/>requireSignin → isAuth → isAdmin"]
G --> H["<b>controllers/product.js</b><br/>create()<br/>slugify(name)"]
H --> I["<b>models/product.js</b><br/>new Product(req.body).save()"]
I --> J[("<b>MongoDB</b><br/>collection products")]
J -->|"document vừa lưu"| H
H -->|"res.json(product)"| F
F -->|"invalidatesTags: ['Product']"| K["cache RTK Query bẩn<br/>→ ListProduct tự gọi lại"]
F -->|".unwrap().then(...)"| L["toast.success + reset()<br/>+ setPreview('')"]
Còn đây là đường về vẽ phẳng cho dễ nhớ — cùng một mạch, đọc từ trái sang phải rồi quay ngược lại:
TRÌNH DUYỆT MẠNG SERVER DB
┌──────────────────┐ ┌────────────────┐ ┌───────────────┐ ┌──────────┐
│ AddProductPage │ │ │ │ │ │ │
│ ├ react-hook-form│ │ │ │ │ │ │
│ ├ yup validate │ │ │ │ │ │ │
│ ├ uploadFile ───┼──── ① ──────►│ Cloudinary API │ │ │ │ │
│ │ ◄──┼──── ② ───────│ (trả về url) │ │ │ │ │
│ └ addProduct ───┼──── ③ ──────►│ POST /api/ │─────►│ router │ │ │
│ │ │ products/:uid │ │ ↓ middleware │ │ │
│ │ │ │ │ ↓ controller │──►│ products │
│ │ │ │ │ ↓ model │◄──│ │
│ toast + reset ◄─┼──── ④ ───────┼────────────────┼──────│ res.json() │ │ │
│ cache invalid ──┼──── ⑤ ──────►│ GET /api/ │─────►│ list() │──►│ products │
│ bảng tự vẽ lại ◄┼──────────────┼────────────────┼──────│ │◄──│ │
└──────────────────┘ └────────────────┘ └───────────────┘ └──────────┘
💡 Điểm quan trọng nhất của sơ đồ: ảnh KHÔNG đi qua server Yotea. Trình duyệt tự đẩy file lên Cloudinary (bước ①②), rồi chỉ gửi một chuỗi URL về backend (bước ③). Backend Yotea không hề biết đến file ảnh, không có thư mục
uploads/, không càimulter.
1. Khu /admin gồm những gì
1.1. Cửa vào
yotea-fe/src/App.js:188-199
{
path: "/admin",
element: (
<PrivateRouter page="admin">
<AdminLayout />
</PrivateRouter>
),
children: [
{
path: "",
element: <Dashboard />,
},
Mọi thứ trong /admin đều nằm bên trong <PrivateRouter page="admin"> (đã học ở Bài 24) và bên trong <AdminLayout />. Muốn thêm màn quản trị mới, bạn chỉ việc thêm một phần tử vào mảng children này.
1.2. Điểm danh đủ 8 nhóm màn hình quản trị
| # | Nhóm | URL gốc | Trang danh sách | Trang thêm | Trang sửa / chi tiết | Phân trang? |
|---|---|---|---|---|---|---|
| 1 | Đơn hàng | /admin/cart |
cart/CartListPage.js |
— (khách tự đặt) | cart/CartDetailPage.js |
✅ |
| 2 | Người dùng | /admin/user |
user/UserListPage.js |
user/AddUserPage.js |
user/EditUserPage.js |
✅ |
| 3 | Tin tức | /admin/news |
news/NewsListPage.js |
news/AddNewsPage.js |
news/EditNewsPage.js |
✅ |
| 4 | Danh mục tin | /admin/category-news |
category-news/CateNewsListPage.js |
AddCateNewsPage.js |
EditCateNewsPage.js |
❌ |
| 5 | Sản phẩm | /admin/product |
product/ProductListPage.js |
product/AddProductPage.js |
product/EditProductPage.js |
✅ |
| 6 | Danh mục SP | /admin/category |
category/CategoryListPage.js |
category/AddCategoryPage.js |
category/EditCategoryPage.js |
❌ |
| 7 | Slider | /admin/slider |
slider/SliderListPage.js |
slider/AddSlidePage.js |
slider/EditSlidePage.js |
❌ |
| 8 | Liên hệ | /admin/contact |
contact/ContactListPage.js |
— (khách tự gửi) | contact/ContactDetailPage.js |
✅ |
Cộng thêm Dashboard.js là 9 mục trong sidebar.
1.3. Những màn là code chết
| File | Vì sao chết |
|---|---|
pages/admin/profile/AdminUpdateInfoPage.js |
App.js không import, không có route /admin/profile |
pages/admin/profile/AdminUpdatePassword.js |
như trên |
components/admin/StoreList.js |
không file nào import; cũng không có route /admin/store |
components/admin/AdminCommentList.js |
không file nào import → màn kiểm duyệt bình luận chưa được nối vào router |
Bạn có thể tự kiểm chứng bằng lệnh sau (đứng ở gốc repo):
grep -rn "AdminCommentList\|StoreList\|AdminUpdateInfoPage" yotea-fe/src
Kết quả chỉ ra đúng dòng khai báo và dòng export default trong chính file đó — không có nơi nào import.
⚠️ Chỗ này dự án làm chưa chuẩn: viết xong 4 file rồi quên nối vào router là chuyện rất hay xảy ra ở bài tập lớn. Cách phòng: mỗi khi tạo một page mới, việc đầu tiên là thêm route và bấm thử URL đó, rồi mới ngồi viết giao diện. Ngoài ra hãy bật ESLint rule
no-unused-moduleshoặc chạynpx depcheck/npx unimportedđịnh kỳ để soi file mồ côi.
2. Mổ AdminLayout.js — bộ khung của mọi màn quản trị
File: yotea-fe/src/pages/layouts/AdminLayout.js (236 dòng). Lưu ý đường dẫn: nó nằm trong pages/layouts/, không phải layouts/.
2.1. Đầu file — lấy user và hàm đăng xuất
yotea-fe/src/pages/layouts/AdminLayout.js:17-24
const AdminLayout = () => {
const { user } = useSelector(selectAuth);
const dispatch = useDispatch();
const handleLogout = () => {
dispatch(logout());
};
Đọc từng dòng:
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 18 | const { user } = useSelector(selectAuth); |
Lấy state.auth.value.user — dùng để hiện avatar ở góc phải |
| 20 | const dispatch = useDispatch(); |
Chuẩn bị để bắn action |
| 22-24 | handleLogout |
dispatch(logout()) — reducer đặt value = {} và isLogged = false, PrivateRouter lập tức đá về /login |
2.2. Sidebar — 9 mục menu
Sidebar là thẻ <nav className="dashboard__sidebar ..."> cố định bên trái, rộng w-60. Mỗi mục là một <NavLink>. Đây là mục đầu tiên, trích nguyên văn:
yotea-fe/src/pages/layouts/AdminLayout.js:40-53
<NavLink
to="/admin/"
className="sidebar__item flex items-center px-4 py-3 transition cursor-pointer group hover:bg-gray-800 hover:text-gray-200"
>
<svg
className="shrink-0 w-5 h-5 mr-2 text-gray-400 transition group-hover:text-gray-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentcolor"
>
<path d="M10.707 2.293a1 1 0 00-1.414.0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg>
<span>Dashboard</span>
</NavLink>
8 mục còn lại có cấu trúc y hệt, chỉ khác to, icon và chữ:
| Dòng | to |
Icon | Nhãn |
|---|---|---|---|
| 40-53 | /admin/ |
SVG ngôi nhà (inline) | Dashboard |
| 55-63 | /admin/cart |
faShoppingCart |
Cart |
| 65-75 | /admin/user |
faNewspaper ⚠️ |
Users |
| 77-87 | /admin/news |
faNewspaper |
News |
| 89-99 | /admin/category-news |
faThList |
Categories News |
| 101-111 | /admin/product |
faProductHunt |
Products |
| 113-123 | /admin/category |
faThList |
Categories Products |
| 125-135 | /admin/slider |
faSlidersH |
Slider |
| 137-147 | /admin/contact |
faComment |
Contact |
⚠️ Chỗ này dự án làm chưa chuẩn: mục Users dùng icon
faNewspaper(tờ báo) — copy-paste từ mục News mà quên đổi. Icon đúng phải làfaUsers/faUser. Ngoài raAdminLayout.js:3,9importfaCodevàfaStorenhưng không dùng ở đâu → import thừa.⚠️ Không mục nào có route con
page/:pagetrong menu, nên khi bạn đang ở/admin/product/page/3thìNavLink"Products" không được tô sáng.NavLinkchỉ tự thêm classactivekhi URL khớp; ở đây dự án cũng không truyền hàmclassName={({isActive}) => ...}nên thực ra không mục nào bao giờ đổi màu — chỉ cóhover.
2.3. Thanh header trên cùng và <Outlet />
yotea-fe/src/pages/layouts/AdminLayout.js:225-227
<main>
<Outlet />
</main>
📖 Thuật ngữ: <Outlet /> — cái "lỗ" trong layout để React Router nhét route con vào.
Khi URL là /admin/product, router render <AdminLayout /> rồi thay <Outlet /> bằng
<ProductListPage />. Nhờ vậy sidebar và header không bị vẽ lại khi bạn chuyển màn.
Vùng nội dung được đẩy sang phải bằng className="ml-0 transition md:ml-60" (:150) để không bị sidebar w-60 che.
2.4. ⚠️ Link chết /admin/profile
yotea-fe/src/pages/layouts/AdminLayout.js:208-213
<Link
to="/admin/profile"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Profile
</Link>
⚠️ Chỗ này dự án làm chưa chuẩn — LINK CHẾT: trong
App.jskhông có route nào khớp/admin/profile(xem lại bảng route ở mục 1.2). Bấm vào mục Profile trong menu avatar → React Router không tìm ra route, màApp.jscũng không khai báoerrorElement→ bạn nhận được trang trắng kèm thông báo lỗi mặc định của router.Hai file đáng lẽ phải phục vụ link này —
pages/admin/profile/AdminUpdateInfoPage.jsvàAdminUpdatePassword.js— đã viết xong nhưng không được import vàoApp.js.Cách làm đúng: hoặc (a) thêm route
{ path: "profile", element: <AdminUpdateInfoPage /> }vào mảngchildrencủa/admin, hoặc (b) xoá luôn mục menu. Và luôn khai báoerrorElementcho route gốc để URL sai ra trang 404 tử tế thay vì màn trắng. Ta sẽ làm cả hai ở Bài 34.
2.5. Những nút chỉ để trang trí
| Vị trí | Thứ | Vấn đề |
|---|---|---|
:152-157 |
Nút ☰ mở sidebar trên mobile |
Không có onClick → trên điện thoại sidebar -translate-x-full không bao giờ mở ra được |
:158-167 |
Ô Search ở header | Không onSubmit, không state |
:169-190 |
Chuông thông báo | Không onClick |
:229 |
<div ... dashboard__overlay /> |
Lớp phủ khi mở sidebar mobile, hidden cứng, không ai bật |
⚠️ Đây là dấu vết của việc bê nguyên một template HTML tĩnh (Tailwind UI) vào React mà chưa nối logic. Rất phổ biến trong đồ án. Khi bạn gặp một nút "không làm gì", đừng vội nghĩ mình đọc sót — hãy
greptên class/id của nó, thường là không có handler thật.
3. Mổ Dashboard.js — sự thật phũ phàng
Toàn bộ file, yotea-fe/src/pages/admin/Dashboard.js:1-7:
import React from "react";
const Dashboard = () => {
return <div>Dashboard</div>;
};
export default Dashboard;
Nó hiển thị số liệu gì? — Không có số liệu nào. Bảy dòng, in ra đúng chữ Dashboard.
- Không
useState, khônguseEffect, không gọi API, khônguseSelector. - Không có ô "Doanh thu hôm nay", không biểu đồ, không đếm đơn hàng / sản phẩm / user.
- Có tính đúng không? Câu hỏi không áp dụng — không có phép tính nào để mà sai.
⚠️ Chỗ này dự án làm chưa chuẩn: route mặc định của cả khu quản trị (
/admin) trỏ vào một component rỗng. Người dùng đăng nhập admin xong nhìn thấy màn hình gần như trắng.Cách làm đúng (gợi ý để bạn tự làm ở phần Bài tập): Dashboard nên gọi vài API sẵn có rồi tính: tổng đơn (
GET /api/orders→data.length), doanh thu (reducecộngtotalPrice - priceDecreasecủa các đơnstatus === 3), tổng sản phẩm, tổng user. Tuy nhiên backend không trả vềtotaltrong header hay body, nên hiện tại muốn đếm là phải tải hết bản ghi về rồi lấy.length— đúng cái anti-pattern mà cả dự án đang mắc (xem mục 4.2).
4. KHUÔN MẪU CHUNG của mọi màn quản trị
Đây là mục quan trọng nhất bài. Nắm được khuôn này thì 8 nhóm màn hình kia bạn đọc lướt trong 5 phút.
┌─────────────────────────────────────────┐
/admin/<res> │ TRANG DANH SÁCH (<Res>ListPage.js) │
/admin/<res>/page/n │ ├ đọc :page từ useParams() │
│ ├ công thức phân trang (limit=10) │
│ ├ <List<Res> start limit /> ← bảng │
│ │ ├ gọi API / thunk / RTK Query │
│ │ ├ nút Edit → Link │
│ │ └ nút Delete → Swal → xoá │
│ └ <AdminPagination page totalPage url/>│
└────────────┬───────────────┬────────────┘
"Thêm" ─────────┘ └───────── "Edit"
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ TRANG THÊM (Add<Res>Page.js) │ │ TRANG SỬA (Edit<Res>Page.js) │
│ ├ schema yup (CÓ image) │ │ ├ schema yup (BỎ image) │
│ ├ useForm + yupResolver │ │ ├ useEffect → get(slug|id) │
│ ├ preview ảnh (useState) │ │ ├ reset(data) nạp giá trị cũ │
│ ├ uploadFile(...) → url │ │ ├ chỉ upload NẾU chọn ảnh mới│
│ ├ POST │ │ ├ PUT │
│ └ toast.success + reset() │ │ └ toast.success + navigate() │
└──────────────────────────────┘ └──────────────────────────────┘
4.1. Trang danh sách — công thức phân trang lặp y hệt 5 lần
yotea-fe/src/pages/admin/product/ProductListPage.js:6-17
const ProductListPage = () => {
const { data: products } = useGetProductsQuery({});
const totalItem = products?.length || 0;
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;
Đọc từng dòng:
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 7 | useGetProductsQuery({}) |
Gọi RTK Query không truyền start/limit → tải TOÀN BỘ sản phẩm |
| 8 | products?.length \|\| 0 |
Dùng độ dài mảng làm tổng số bản ghi |
| 10 | const { page } = useParams() |
Lấy số trang từ URL /admin/product/page/3 → page = "3" (chuỗi!) |
| 12 | const limit = 10 |
Mỗi trang 10 dòng — hằng số cứng, lặp ở cả 5 trang list |
| 13 | Math.ceil(totalItem / limit) |
27 bản ghi ÷ 10 = 2.7 → 3 trang |
| 14 | Number(page) \|\| 1 |
Đổi chuỗi sang số; undefined/NaN/0 đều rơi về 1 |
| 15-16 | ternary kẹp | Ép currentPage nằm trong [1, totalPage] |
| 17 | start |
Bỏ qua bao nhiêu bản ghi: trang 3 → (3-1)*10 = 20 |
Đoạn 6 dòng này (từ const limit = 10) lặp nguyên xi ở CartListPage:11-16, ContactListPage:12-17, NewsListPage:12-17, ProductListPage:12-17, UserListPage:14-19.
⚠️ Chỗ này dự án làm chưa chuẩn — 2 request cho 1 trang:
ProductListPagegọiuseGetProductsQuery({})để lấy tổng, rồiListProductbên trong lại gọiuseGetProductsQuery({ start, limit })để lấy đúng 10 dòng. Mỗi lần vào trang là 2 request, và request đầu kéo về toàn bộ bảng sản phẩm chỉ để đếm.Nguyên nhân gốc: backend
list()trả về mảng trần, không kèm tổng số. Cách đúng là backend trả{ data: [...], total: 27 }hoặc set headerX-Total-Count, rồi FE đọc header đó. Ta sẽ sửa ở Bài 34.⚠️ Bug clamp khi bảng rỗng:
totalItem = 0→totalPage = 0→ dòng 15-16 thấycurrentPage (1) > totalPage (0)nên gáncurrentPage = 0. Kết quả<AdminPagination page={0}>. May làAdminPaginationchỉ render dãy số khitotalPage > 1nên không lộ ra, nhưng logic vẫn sai. Cách đúng:const totalPage = Math.max(1, Math.ceil(totalItem / limit));
4.2. Bảng dữ liệu + nút Xoá với SweetAlert2
yotea-fe/src/components/admin/ListProduct.js:8-30
const ListProduct = ({ start, limit }) => {
const { data: products } = useGetProductsQuery({ start, limit });
const [deleteProduct] = useDeleteProductMutation();
const handleRemove = 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) {
deleteProduct(id)
.unwrap()
.then(() => {
Swal.fire("Thành công!", "Đã xóa thành công.", "success");
});
}
});
};
📖 Thuật ngữ: SweetAlert2 (Swal) — thư viện hộp thoại đẹp thay cho alert()/confirm() xấu
xí của trình duyệt. Swal.fire({...}) trả về một Promise; khi người dùng bấm nút, promise
resolve với object result, trong đó result.isConfirmed === true nghĩa là họ bấm nút xác nhận.
Khối Swal.fire này được copy y hệt ở 8 file: CategoryListPage, CateNewsList, ContactList,
ListProduct, ListSlider, NewsList, StoreList, UserList, AdminCommentList.
✅
ListProductlà component xoá DUY NHẤT làm đúng. Nó dùng.unwrap().then(...)— chỉ báo "Đã xóa thành công" sau khi server trả 2xx.⚠️ 6 component còn lại viết kiểu này (ví dụ
ContactList.js:31-32):dispatch(deleteContact(id)); Swal.fire("Thành công!", "Đã xóa thành công.", "success");
dispatch(thunk)trả về promise nhưng không ai chờ, vàcreateAsyncThunknuốt lỗi (không throw ra ngoài). Kết quả: server trả 401/500 thì người dùng vẫn thấy "Đã xóa thành công" trong khi dòng dữ liệu vẫn còn nguyên sau khi F5. Cách đúng:await dispatch(deleteContact(id)).unwrap()bọc trongtry/catch.⚠️ Ngay cả
ListProductcũng thiếu.catch()→ xoá lỗi thì im lặng, không báo gì.
Phần thân bảng, yotea-fe/src/components/admin/ListProduct.js:95-102:
{products?.map((item, index) => (
<tr key={index}>
<td /* ...class Tailwind... */>
{++index + start}
</td>
<td /* ...class Tailwind... */>
{item._id}
</td>
⚠️
{++index + start}— sửa thẳng tham sốindexcủa.map. Nó chạy được (STT trang 3 bắt đầu từ 21) nhưng là anti-pattern kinh điển: biến của callback không nên bị mutate. Lặp ở 6 file. Cách đúng:{index + 1 + start}.⚠️
key={index}— dùng chỉ số mảng làmkey. Khi xoá dòng giữa bảng, React ghép nhầm DOM cũ với dữ liệu mới. Cách đúng:key={item._id}.
Cuối mỗi dòng là cặp nút Edit/Delete, yotea-fe/src/components/admin/ListProduct.js:141-152:
<Link
to={`/admin/product/${item.slug}/edit`}
className=/* ...class Tailwind... */
>
Edit
</Link>
<button
className=/* ...class Tailwind... */
onClick={() => handleRemove(item._id)}
>
Delete
</button>
Chú ý: Edit dùng slug, Delete dùng _id. Hai khoá khác nhau trong cùng một dòng bảng — nhớ kỹ điều này, mục 6 sẽ cho thấy nó gây bug thế nào.
4.3. Trang thêm — react-hook-form + yup + toast
Bộ ba luôn đi cùng nhau:
| Thư viện | Việc |
|---|---|
react-hook-form |
Quản lý giá trị các ô input mà không cần useState cho từng ô |
yup + @hookform/resolvers/yup |
Mô tả luật hợp lệ bằng schema, tự sinh thông báo lỗi tiếng Việt |
react-toastify |
Bật thông báo nhỏ ở góc màn hình (<ToastContainer /> đặt ở App.js:356) |
Xem chi tiết ở mục 5.
4.4. Trang sửa — bí quyết reset()
Trang sửa khác trang thêm đúng ba điểm:
- Schema bỏ trường ảnh (để sửa mà không bắt chọn lại ảnh).
- Có
useEffectnạp bản ghi cũ rồi gọireset(data)để đổ vào form. - Chỉ upload ảnh khi người dùng thực sự chọn file mới.
📖 Thuật ngữ: reset(values) — hàm của react-hook-form, ghi đè toàn bộ giá trị form bằng
object bạn truyền vào. Đây là cách chuẩn để "nạp dữ liệu cũ" vì dữ liệu về bất đồng bộ (sau khi
component đã render xong), nên không thể dùng defaultValues lúc khởi tạo useForm.
5. Soi kỹ AddProductPage.js
5.1. Schema yup
yotea-fe/src/pages/admin/product/AddProductPage.js:15-27
const schema = yup.object().shape({
name: yup.string().required("Vui lòng nhập tên sản phẩm"),
price: yup
.string()
.required("Vui lòng nhập giá sản phẩm")
.test("min", "Vui lòng nhập lại giá SP", (value) => Number(value) >= 0),
description: yup.string().required("Vui lòng nhập mô tả SP"),
categoryId: yup.string().required("Vui lòng chọn danh mục SP"),
image: yup
.mixed()
.test("require", "Vui lòng chọn ảnh SP", (value) => value.length),
status: yup.number().required("Vui lòng chọn trạng thái SP"),
});
Đọc từng dòng:
| Dòng | Luật | Giải thích |
|---|---|---|
| 16 | name bắt buộc |
chuỗi rỗng → báo lỗi |
| 17-20 | price là string rồi mới Number(value) >= 0 |
yup.mixed().test(tên, thông_báo, hàm_kiểm); hàm trả false là lỗi |
| 21 | description bắt buộc |
|
| 22 | categoryId bắt buộc |
giá trị là _id của danh mục |
| 23-25 | image là mixed() + value.length |
<input type="file"> trả về một FileList; chưa chọn file thì length === 0 → falsy → lỗi. Đây chính là chỗ "bắt buộc phải upload ảnh khi THÊM" |
| 26 | status là number |
yup tự ép "1" → 1 |
⚠️ Chỗ này dự án làm chưa chuẩn — 3 lỗ hổng validate:
price: yup.string()+Number(value) >= 0:Number("") === 0nên ô giá để trống vẫn lọt qua testmin(may là.required()chặn được), cònNumber("abc")làNaN, vàNaN >= 0làfalsenên báo lỗi — đúng nhưng thông báo sai ngữ nghĩa. Cách đúng:yup.number().typeError("Giá phải là số").min(0, "...").required("...").status: yup.number().required(): chọn option placeholder""→ yup ép thànhNaN, màNaNkhông phảiundefinednên.required()vẫn pass → có thể submit khi chưa chọn trạng thái. Cách đúng: thêm.typeError("Vui lòng chọn trạng thái SP").image: value.length— nếuvaluelàundefined(trình duyệt lạ) thì đọc.lengthgây TypeError trong resolver. Cách đúng:(value) => value?.length > 0.
5.2. Ô chọn danh mục lấy dữ liệu từ đâu
yotea-fe/src/pages/admin/product/AddProductPage.js:29-41
const AddProductPage = () => {
const dispatch = useDispatch();
const categories = useSelector(selectCatesProduct);
const [addProduct] = useAddProductMutation();
const [preview, setPreview] = useState();
useEffect(() => {
dispatch(getCates());
}, []);
const handlePreview = (e) => {
setPreview(URL.createObjectURL(e.target.files[0]));
};
Đường đi của danh sách danh mục:
useEffect → dispatch(getCates()) [redux/categoryProductSlice.js:8-14]
└─ getAll() [api/category.js:7-11]
└─ GET /api/category/?_sort=createdAt&_order=desc
└─ routes/category.js → controllers/category.js list()
└─ Category.find(...) → MongoDB
← payload → state.cateProduct.value
useSelector(selectCatesProduct) [categoryProductSlice.js:64]
→ categories → <option> trong <select>
Và đây là ô <select>, yotea-fe/src/pages/admin/product/AddProductPage.js:166-178:
<select
id="form__add-product-cate"
{...register("categoryId")}
defaultValue=""
className=/* ...class Tailwind... */
>
<option value="">-- Chọn danh mục sản phẩm --</option>
{categories?.map((cate, index) => (
<option key={index} value={cate._id}>
{cate.name}
</option>
))}
</select>
Điểm mấu chốt: value={cate._id} — cái gửi lên server là ObjectId của danh mục, còn {cate.name} chỉ là chữ cho người xem.
Ô trạng thái, yotea-fe/src/pages/admin/product/AddProductPage.js:190-199:
<select
id="form__add-product-stt"
{...register("status")}
defaultValue={0}
className=/* ...class Tailwind... */
>
<option value="">-- Chọn trạng thái sản phẩm --</option>
<option value={0}>Ẩn</option>
<option value={1}>Hiển thị</option>
</select>
⚠️
defaultValue={0}đặt trên<select>đã có{...register(...)}là xung đột: react-hook-form mới là bên quản lý giá trị. Cách đúng:useForm({ resolver, defaultValues: { status: 0 } }).
5.3. Ô chọn file và preview
yotea-fe/src/pages/admin/product/AddProductPage.js:245-251
<input
id="form__add-product-image"
{...register("image")}
onChange={(e) => handlePreview(e)}
type="file"
className="sr-only"
/>
Đọc từng dòng:
| Dòng | Ý nghĩa |
|---|---|
| 247 | {...register("image")} trải ra { name, onChange, onBlur, ref } của react-hook-form |
| 248 | onChange viết SAU spread → ghi đè onChange của RHF |
| 250 | className="sr-only" — giấu input thật, người dùng bấm vào <label> đẹp ở trên (:240-244) |
⚠️ Chỗ này dự án làm chưa chuẩn — thứ tự spread: vì
onChangecủa RHF bị ghi đè, react-hook-form chỉ còn nắm được file thông quaref(RHF v7 đọcref.current.fileslúc submit) → may mà vẫn chạy. Nhưng đây là pattern rất dễ vỡ. Cách đúng:// đoạn này bạn tự viết thêm, dự án chưa có const { onChange: rhfOnChange, ...rest } = register("image"); <input type="file" {...rest} onChange={(e) => { rhfOnChange(e); handlePreview(e); }} />⚠️
URL.createObjectURL(...)(:40) không bao giờ đượcURL.revokeObjectURL→ mỗi lần chọn ảnh lại giữ thêm một blob trong bộ nhớ. Lặp ở 9 form. Cách đúng: lưu url cũ và gọiURL.revokeObjectURL(urlCu)trước khi tạo url mới, hoặc dọn tronguseEffectcleanup.⚠️ Input không có
accept="image/*", không kiểm dung lượng dù UI ghi "PNG, JPG, GIF up to 10MB" (:256) — dòng chữ đó chỉ là trang trí.
5.4. onSubmit — nơi mọi thứ gặp nhau
yotea-fe/src/pages/admin/product/AddProductPage.js:50-69
const onSubmit = async (data) => {
try {
const url = await uploadFile(data.image[0]);
addProduct({
...data,
image: url,
price: +data.price,
status: +data.status,
})
.unwrap()
.then(() => {
toast.success("Thêm sản phẩm thành công");
setPreview("");
reset();
});
} catch (error) {
toast.error("Có lỗi xảy ra, vui lòng thử lại");
}
};
Đọc từng dòng:
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 52 | uploadFile(data.image[0]) |
data.image là FileList; [0] là File đầu tiên. Chờ upload xong mới đi tiếp |
| 54 | addProduct({...}) |
Mutation của RTK Query → POST /api/products/:userId |
| 57 | price: +data.price |
Dấu + đứng trước chuỗi là cách ép sang số ngắn gọn: +"35000" === 35000 |
| 58 | status: +data.status |
tương tự |
| 60 | .unwrap() |
Biến kết quả mutation thành promise "thật": thành công thì resolve, lỗi thì reject |
| 61-65 | .then(...) |
Chỉ chạy khi server trả 2xx: toast + xoá preview + reset() làm trắng form |
⚠️ Chỗ này dự án làm chưa chuẩn —
try/catchbắt hụt: khốitrychỉ ôm lời gọi đồng bộ. Khi.unwrap()reject (API 400/401/500), promise vỡ ngoài phạm vitry→ trình duyệt inUnhandled promise rejection, không có toast lỗi, người dùng bấm mãi không hiểu vì sao. Cách đúng: thêm.catch(() => toast.error("..."))sau.then(...), hoặc dùngawait addProduct({...}).unwrap()để lỗi rơi đúng vàocatch.
6. Soi kỹ EditProductPage.js
6.1. Schema — bỏ hẳn image
yotea-fe/src/pages/admin/product/EditProductPage.js:15-24
const schema = yup.object().shape({
name: yup.string().required("Vui lòng nhập tên sản phẩm"),
price: yup
.string()
.required("Vui lòng nhập giá sản phẩm")
.test("min", "Vui lòng nhập lại giá SP", (value) => Number(value) >= 0),
description: yup.string().required("Vui lòng nhập mô tả SP"),
categoryId: yup.string().required("Vui lòng chọn danh mục SP"),
status: yup.number().required("Vui lòng chọn trạng thái SP"),
});
So với AddProductPage.js:15-27 — thiếu đúng khối image. Đó là chủ ý: sửa sản phẩm mà không muốn đổi ảnh thì để trống ô file.
6.2. Nạp dữ liệu cũ bằng reset()
yotea-fe/src/pages/admin/product/EditProductPage.js:64-76
useEffect(() => {
dispatch(getCates());
const getProduct = async () => {
const { data } = await get(slug);
setPreview(data.image);
reset({
...data,
categoryId: data.categoryId?._id,
});
};
getProduct();
}, []);
Đọc từng dòng:
| Dòng | Ý nghĩa |
|---|---|
| 65 | Nạp danh sách danh mục cho <select> (giống trang thêm) |
| 68 | get(slug) → GET /api/products/<slug>/?_expand=categoryId |
| 69 | Đổ URL ảnh cũ vào ô preview → người dùng thấy ngay ảnh hiện tại |
| 70-73 | reset() đổ toàn bộ bản ghi vào form |
| 72 | Điểm dạy quan trọng: vì có _expand=categoryId, data.categoryId là cả object danh mục, không phải chuỗi id. <select> chỉ so khớp được với chuỗi, nên phải rút ._id ra |
💡 Mẹo: mỗi khi trang sửa có
<select>mà "không tự chọn đúng mục cũ", 90% là do bạn quên chuyển object đãpopulatevề_idnhư dòng 72.
6.3. Giữ ảnh cũ nếu không chọn ảnh mới
yotea-fe/src/pages/admin/product/EditProductPage.js:43-58
const onSubmit = async (data) => {
try {
if (typeof data.image === "object" && data.image.length) {
data.image = await uploadFile(data.image[0]);
}
updateProduct(data)
.unwrap()
.then(() => {
toast.success("Cập nhật SP thành công");
navigate("/admin/product");
});
} catch (error) {
toast.error("Có lỗi xảy ra, vui lòng thử lại");
}
};
Dòng 45 là trái tim của trang sửa. Giải thích thật kỹ:
| Tình huống | data.image là gì |
typeof |
.length |
Kết quả |
|---|---|---|---|---|
| Người dùng không chọn ảnh mới | Chuỗi URL cũ do reset() nạp vào, ví dụ "http://res.cloudinary.com/..." |
"string" |
(không xét) | Điều kiện false → bỏ qua upload → gửi nguyên URL cũ lên server ✅ |
| Người dùng có chọn ảnh mới | FileList có 1 phần tử |
"object" |
1 |
Điều kiện true → upload → thay data.image bằng URL mới ✅ |
| Bấm vào ô file rồi bấm Cancel | FileList rỗng |
"object" |
0 |
Điều kiện false → giữ ảnh cũ ✅ |
Mẫu 3 dòng này lặp y hệt ở EditCategoryPage:43-45, EditNewsPage:40-42, EditSlidePage, EditUserPage:67-69, AdminUpdateInfoPage.
⚠️ BUG NẶNG — trang sửa sản phẩm không nạp được dữ liệu:
App.js:274-277khai báo route là":id/edit", nhưngEditProductPage.js:32lại đọcconst { slug } = useParams();. Tên tham số không khớp →slug === undefined→ gọiget(undefined)→ requestGET /api/products/undefined→ form trống trơn.Nghịch lý: giá trị trong URL lại đúng là slug (do
ListProduct.js:142sinh ra/admin/product/${item.slug}/edit). Chỉ có tên tham số trongApp.jsbị đặt sai. Cách đúng: đổiApp.jsthànhpath: ":slug/edit". Cùng lỗi ởEditCategoryPage:28,EditNewsPage:27,EditCateNewsPage:25. RiêngEditSlidePage:22vàEditUserPage:50dùngid— đúng, vì route của chúng cũng là:idvà link sinh ra từitem._id.⚠️ Bất nhất giữa thêm và sửa:
AddProductPageépprice: +data.price,status: +data.status;EditProductPagegửi thẳngdatanênprice/statuslà chuỗi. May là Mongoose tự cast"1"→1theo schema, nhưng đây vẫn là bất nhất rõ rệt.⚠️
EditProductPage.js:266có<div className="error-image ..." />rỗng, không binderrors— tàn dư của trang thêm, code chết.
7. UPLOAD ẢNH CLOUDINARY
7.1. Toàn bộ hàm uploadFile
yotea-fe/src/utils/index.js:3-12
export const uploadFile = async (fileName) => {
const formData = new FormData();
formData.append("file", fileName);
formData.append("upload_preset", "kkio3wiw");
const { data } = await axios.post(
"https://api.cloudinary.com/v1_1/levantuan/image/upload",
formData
);
return data.url;
};
Mười dòng này là toàn bộ hệ thống upload ảnh của Yotea. Không có multer, không có thư mục uploads/ trên server, backend không hề đụng vào file.
Đọc từng dòng:
| Dòng | Code | Ý nghĩa |
|---|---|---|
| 3 | uploadFile(fileName) |
Tham số đặt tên nhầm — nó nhận object File, không phải tên file |
| 4 | new FormData() |
Tạo "phong bì" kiểu multipart/form-data |
| 5 | append("file", fileName) |
Nhét file vào trường file — tên trường Cloudinary quy định |
| 6 | append("upload_preset", "kkio3wiw") |
Nhét tên preset; đây là "vé" cho phép upload không cần chữ ký |
| 7-10 | axios.post(url, formData) |
axios thấy body là FormData nên tự đặt Content-Type: multipart/form-data; boundary=... |
| 11 | return data.url |
Trả về một chuỗi URL |
7.2. FormData là gì
📖 Thuật ngữ: FormData — API sẵn có của trình duyệt, mô phỏng một biểu mẫu HTML có
enctype="multipart/form-data". Khác với JSON (chỉ chở được chữ và số), FormData chở được dữ liệu
nhị phân (ảnh, video, PDF).
POST /v1_1/levantuan/image/upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAbC123
------WebKitFormBoundaryAbC123
Content-Disposition: form-data; name="file"; filename="tra-sua-tran-chau.jpg"
Content-Type: image/jpeg
<...hàng trăm KB dữ liệu nhị phân của tấm ảnh...>
------WebKitFormBoundaryAbC123
Content-Disposition: form-data; name="upload_preset"
kkio3wiw
------WebKitFormBoundaryAbC123--
boundary là chuỗi ngẫu nhiên đóng vai trò "vách ngăn" giữa các phần. Bạn không được tự đặt
Content-Type khi gửi FormData — nếu đặt tay, boundary sẽ mất và server không tách được các phần.
7.3. Unsigned upload là gì
Cloudinary có hai cách nhận file:
| Signed upload | Unsigned upload | |
|---|---|---|
| Ai ký | Server của bạn, dùng API Secret | Không ai ký |
| Vé vào cửa | Chữ ký signature + timestamp |
Chỉ cần tên upload preset |
| API Secret lộ ra trình duyệt? | Không | Không (vì không dùng đến) |
| Ai upload được? | Chỉ người server bạn cho phép | Bất kỳ ai biết cloud name + tên preset |
| Kiểm soát | Chặt | Lỏng — chỉ giới hạn bằng cấu hình preset |
| Dùng khi | Ứng dụng thật, cần kiểm duyệt | Đồ án, prototype, ảnh công khai |
📖 Thuật ngữ: upload preset — một "hồ sơ cấu hình sẵn" trên Cloudinary: lưu vào thư mục nào,
tự resize không, định dạng gì, có cho phép unsigned không. Khi bạn gửi kèm upload_preset=kkio3wiw,
Cloudinary tra ra hồ sơ đó và áp dụng.
🔒 Ghi chú bảo mật: unsigned preset nghĩa là bất kỳ ai xem source trang web của bạn (Ctrl+U hoặc tab Network) đều đọc được cloud name + preset, và có thể tự viết một vòng lặp đẩy hàng nghìn ảnh rác lên tài khoản của bạn cho tới khi hết quota. Với dự án thật, cách đúng là: FE xin server một chữ ký (
GET /api/cloudinary/signature), server ký bằng API Secret giữ trong.env, FE gửi file kèm chữ ký đó. Cloudinary còn cho phép giới hạn preset theo Allowed origins — hãy bật.
7.4. ⚠️ Cloud name và preset trong dự án là của người khác
⚠️ Chỗ này dự án làm chưa chuẩn — HARDCODE TÀI KHOẢN NGƯỜI KHÁC:
- Cloud name:
levantuan- Upload preset:
kkio3wiwĐây là tài khoản Cloudinary của tác giả gốc bài giảng mà đồ án này tham khảo, không phải của bạn. Nó bị nhúng cứng vào mã nguồn, không qua biến môi trường.
Hệ quả bạn sẽ gặp ngay khi chạy dự án hôm nay:
- Tài khoản đó có thể đã bị xoá, khoá, hoặc đã tắt unsigned preset → mọi lần bấm "Thêm sản phẩm" đều thất bại ở bước upload, và vì
try/catchbắt hụt (mục 5.4) nên bạn chỉ thấy... không có gì xảy ra.- Nếu nó còn sống, thì mọi ảnh bạn upload đang nằm trên tài khoản của người lạ — họ xoá lúc nào thì toàn bộ ảnh sản phẩm của bạn hỏng lúc đó.
- Ảnh mẫu (placeholder)
https://res.cloudinary.com/levantuan/image/upload/v1644302455/...lặp ở 9 file cũng thuộc tài khoản này.Bắt buộc: trước khi làm phần "Tự tay làm", bạn phải có tài khoản Cloudinary của chính mình.
7.5. Tự tạo tài khoản Cloudinary miễn phí — làm theo từng bước
Bước 1 — Đăng ký (2 phút).
- Mở trình duyệt, vào
https://cloudinary.com/users/register_free. - Điền email, mật khẩu, tên. Chọn Role = Developer, Primary interest = Programmable Media.
- Bấm Create account → mở hộp thư, bấm link xác nhận.
Bước 2 — Lấy Cloud name.
- Sau khi đăng nhập, bạn đứng ở màn Dashboard (hoặc bấm Programmable Media → Dashboard).
- Nhìn khối Product Environment Credentials, dòng Cloud name — ví dụ
dq7xk2abc. - Chép lại. Đây là thứ sẽ thay cho chữ
levantuantrong URL.
💡 API Key và API Secret ở ngay dưới — đừng chép chúng vào code frontend. Unsigned upload không cần.
Bước 3 — Tạo unsigned upload preset.
- Bấm biểu tượng bánh răng ⚙ (Settings) ở thanh bên trái (hoặc góc trên bên phải).
- Chọn tab Upload → cuộn xuống mục Upload presets.
- Bấm Add upload preset.
- Upload preset name: gõ
yotea_unsigned(hoặc để tên tự sinh, miễn là bạn chép lại). - Signing Mode: đổi từ
SignedsangUnsigned← BƯỚC QUAN TRỌNG NHẤT. - Folder: gõ
yoteađể mọi ảnh gom vào một thư mục cho gọn. - (Tuỳ chọn) tab Upload Manipulations → Incoming transformation → đặt
width 1000, crop limitđể tự thu nhỏ ảnh quá to. - Bấm Save ở góc trên bên phải.
- Quay lại danh sách preset, xác nhận dòng của bạn ghi
Unsigned. Chép tên preset.
Bước 4 — Thử trước bằng terminal (không cần code).
# thay <CLOUD_NAME> và <PRESET> bằng của bạn; đứng ở thư mục nào cũng được
curl -X POST "https://api.cloudinary.com/v1_1/<CLOUD_NAME>/image/upload" \
-F "file=@anh-thu.jpg" \
-F "upload_preset=<PRESET>"
Nếu đúng, bạn nhận JSON dài, trong đó có:
{
"public_id": "yotea/xyz123",
"url": "http://res.cloudinary.com/<CLOUD_NAME>/image/upload/v1.../yotea/xyz123.jpg",
"secure_url": "https://res.cloudinary.com/<CLOUD_NAME>/image/upload/v1.../yotea/xyz123.jpg"
}
Nếu sai preset, bạn nhận {"error":{"message":"Upload preset not found"}}.
Nếu preset còn để Signed, bạn nhận {"error":{"message":"Upload preset must be whitelisted for unsigned uploads"}}.
Bước 5 — Dùng trong dự án. Ở phần "Tự tay làm" bên dưới, bạn sẽ tạo file mới
yotea-fe/src/utils/cloudinary.js chứa hàm upload dùng tài khoản của bạn — không sửa utils/index.js
(ta để dành việc dọn dẹp đó cho Bài 34).
7.6. ⚠️ data.url thay vì data.secure_url
Dòng cuối hàm là return data.url;. Cloudinary trả về cả hai:
| Trường | Giá trị | |
|---|---|---|
data.url |
http://res.cloudinary.com/... |
HTTP — không mã hoá |
data.secure_url |
https://res.cloudinary.com/... |
HTTPS — mã hoá |
⚠️ Chỗ này dự án làm chưa chuẩn: dùng
data.urlnghĩa là URL http:// được ghi thẳng vào database. Khi bạn deploy website lên tên miền https (Vercel/Netlify đều mặc định https), trình duyệt gặp ảnh http trong trang https sẽ coi là mixed content và chặn không tải ảnh (Chrome hiện icon ổ khoá gạch chéo, ảnh vỡ). Tệ hơn: URL đã nằm trong DB rồi, sửa code không đủ — phải chạy script cập nhật lại toàn bộ bản ghi cũ.Cách đúng — đổi đúng một chữ:
// đoạn này bạn tự viết thêm, dự án chưa có return data.secure_url;
8. Phân trang admin — AdminPagination và người anh em phía khách
8.1. AdminPagination.js
yotea-fe/src/components/admin/AdminPagination.js:3-20
const AdminPagination = ({ page, totalPage, url }) => {
const pagination = [];
for (let i = 1; i <= totalPage; i++) {
pagination.push(
<Link
key={i}
to={`/admin/${url}/page/${i}`}
className={`${
page === i
? "z-10 bg-indigo-50 border-indigo-500 text-indigo-600"
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50"
} relative inline-flex items-center px-4 py-2 border text-sm font-medium`}
>
{i}
</Link>
);
}
Đọc từng dòng:
| Dòng | Ý nghĩa |
|---|---|
| 3 | Nhận đúng 3 props, không có state, không gọi API — component thuần trình bày |
| 4-6 | Dựng sẵn một mảng phần tử React bằng vòng for thường (không phải .map) |
| 10 | to={`/admin/${url}/page/${i}`} — tự chèn tiền tố /admin. Nên cha chỉ cần truyền url="product" |
| 12 | page === i — so sánh strict, nên page bắt buộc phải là số (đó là lý do cha ép Number(page)) |
Ba khối điều kiện ở phần return:
{page > 1 && ( <Link to={`/admin/${url}/page/${page - 1}`}> … Previous … </Link> )} // :32-52
{totalPage > 1 && pagination} // :54
{page < totalPage && ( <Link to={`/admin/${url}/page/${page + 1}`}> … Next … </Link> )} // :56-76
8.2. So với Pagination.js phía khách
yotea-fe/src/components/user/Pagination.js:5-22
const Pagination = ({ page, totalPage, url }) => {
const pagination = [];
for (let i = 1; i <= totalPage; i++) {
pagination.push(
<li key={i}>
<Link
to={`/${url}/page/${i}`}
className={`w-10 h-10 rounded-full border-2 flex items-center justify-center font-semibold mx-0.5 cursor-pointer transition ease-linear duration-200 hover:bg-[#D9A953] hover:border-[#D9A953] hover:text-white ${
page === i
? "border-[#D9A953] bg-[#D9A953] text-white"
: "border-gray-500 text-gray-500"
}`}
>
{i}
</Link>
</li>
);
}
Bảng so sánh:
| Tiêu chí | components/admin/AdminPagination.js |
components/user/Pagination.js |
|---|---|---|
| Props | { page, totalPage, url } |
{ page, totalPage, url } — giống hệt |
| Thuật toán | vòng for 1→totalPage |
giống hệt |
| URL sinh ra | `/admin/${url}/page/${i}` — tự chèn /admin |
`/${url}/page/${i}` — cha truyền url đầy đủ |
| Thẻ bọc | <div id="pagination"><nav aria-label="Pagination"> |
<ul className="flex justify-center mt-5"> |
| Mỗi nút | <Link> phẳng |
<li><Link>…</Link></li> |
| Icon Prev/Next | SVG heroicons dán inline | FontAwesome faAngleLeft/faAngleRight |
| Kiểu dáng | chữ nhật liền khối, màu indigo | tròn rounded-full, màu thương hiệu #D9A953 |
limit bên gọi |
10 (mọi trang admin) | 9 (sản phẩm) / 8 (tin tức) / 4 (bình luận) |
| a11y | có aria-label + sr-only |
không có |
| HTML hợp lệ | ✅ | ❌ <button> lồng trong <Link> (:32-34, :47-49) |
⚠️ Cả hai đều thiếu dấu
…: nếu có 500 trang thì in đủ 500 nút, tràn màn hình. Cách đúng là chỉ hiện1 … 4 5 [6] 7 8 … 50.⚠️ Trùng lặp logic: hai file cùng thuật toán, khác mỗi lớp áo. Đúng ra nên là một component nhận thêm prop
variantvàbasePath.
9. Quản lý đơn hàng
9.1. CartListPage.js — trang danh sách đặc biệt (không có "Thêm")
yotea-fe/src/pages/admin/cart/CartListPage.js:6-16
const CartListPage = () => {
const [totalItem, setTotalItem] = useState(0);
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;
Khác ProductListPage: tổng số đơn không tự lấy được, mà con set ngược lên cha qua callback:
yotea-fe/src/pages/admin/cart/CartListPage.js:68-78
<OrderList
onSetTotal={setTotalItem}
start={start}
limit={limit}
/>
<AdminPagination
page={currentPage}
totalPage={totalPage}
url="cart"
/>
9.2. OrderList.js — bảng đơn hàng
yotea-fe/src/components/admin/OrderList.js:9-19
useEffect(() => {
const getOrders = async () => {
const { data } = await getAll();
onSetTotal(data.length);
const { data: orderList } = await getAll(start, limit);
setOrders(orderList);
};
getOrders();
}, [start]);
Lại là mẫu "gọi 2 lần: một lần đếm, một lần lấy trang" — getAll() không truyền limit nên tải toàn bộ đơn hàng về chỉ để đọc .length.
Cột trạng thái, yotea-fe/src/components/admin/OrderList.js:100-118:
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`${
item.status !== 4
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
} px-2 inline-flex text-xs leading-5 font-semibold rounded-full`}
>
{!item.status
? "Đơn hàng mới"
: item.status === 1
? "Đã xác nhận"
: item.status === 2
? "Đang giao hàng"
: item.status === 3
? "Đã giao hàng"
: "Đã hủy"}
</span>
</td>
Cột Actions chỉ có một nút, yotea-fe/src/components/admin/OrderList.js:123-128:
<Link
to={`/admin/cart/${item._id}/detail`}
className=/* ...class Tailwind... */
>
Detail
</Link>
→ Không cho xoá đơn ở màn danh sách. Hợp lý về nghiệp vụ (đơn hàng là chứng từ, chỉ nên huỷ chứ không xoá).
All rights reserved