# Bài 11 — Dựng giao diện với Bootstrap
⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️
🎯 Mục tiêu
Xây dựng 4 component giao diện chính:
Navbar— thanh điều hướng có badge giỏ hàngSlider— carousel bannerProduct— thẻ hiển thị 1 sản phẩmProductlist— lưới sản phẩm
Đồng thời nắm được hệ thống lớp CSS của Bootstrap 5 mà dự án dùng.
📚 1. Bootstrap 5 — những lớp cần biết
Bootstrap là bộ CSS có sẵn. Bạn chỉ cần gán đúng className.
Hệ thống lưới (Grid)
Bootstrap chia màn hình thành 12 cột.
<div className="container">
<div className="row">
<div className="col-sm-3">Sidebar (3/12)</div>
<div className="col-sm-9">Nội dung (9/12)</div>
</div>
</div>
Các breakpoint (điểm gãy theo chiều rộng màn hình):
| Tiền tố | Áp dụng từ | Thiết bị |
|---|---|---|
col- |
mọi kích thước | |
col-sm- |
≥ 576px | Điện thoại ngang |
col-md- |
≥ 768px | Tablet |
col-lg- |
≥ 992px | Laptop |
col-xl- |
≥ 1200px | Màn hình lớn |
row-cols-* — chia đều số cột mỗi hàng
<div className="row row-cols-lg-4 row-cols-md-3 row-cols-2">
{/* Mỗi <div className="col"> tự động chiếm 1/4, 1/3 hoặc 1/2 */}
</div>
Dùng cho lưới sản phẩm: laptop 4 cột, tablet 3 cột, điện thoại 2 cột.
Khoảng cách (Spacing)
Cú pháp: {thuộc-tính}{hướng}-{cỡ}
| Thuộc tính | Hướng | Cỡ | ||
|---|---|---|---|---|
m = margin |
t top, b bottom |
0 = 0 |
||
p = padding |
s start (trái), e end (phải) |
1 = 0.25rem |
||
x trái+phải, y trên+dưới |
2 = 0.5rem |
|||
| (không có) = tất cả | 3 = 1rem |
|||
4 = 1.5rem |
||||
5 = 3rem |
||||
auto |
Ví dụ: mt-5 (margin-top 3rem), px-3 (padding trái phải 1rem), me-auto (margin-right auto).
Flexbox
<div className="d-flex justify-content-between align-items-center gap-3">
| Lớp | Tác dụng |
|---|---|
d-flex |
Bật flexbox |
flex-column |
Xếp dọc thay vì ngang |
justify-content-center / -between / -end |
Canh theo trục chính |
align-items-center |
Canh theo trục phụ |
gap-3 |
Khoảng cách giữa các phần tử |
Màu sắc
| Lớp | Màu |
|---|---|
bg-dark / text-dark |
Đen |
bg-primary / text-primary |
Xanh dương |
bg-danger / text-danger |
Đỏ |
bg-warning / text-warning |
Vàng |
bg-success / text-success |
Xanh lá |
bg-light |
Xám nhạt |
*-subtle |
Phiên bản nhạt (VD: bg-primary-subtle) |
Component
btn btn-dark,btn btn-outline-dark,btn-lg,btn-smcard,card-body,card-title,card-text,card-img-top,card-headernavbar,nav-link,dropdown,dropdown-menu,dropdown-itemtable,form-control,form-select,form-labelbadge,modal,carousel,list-group
💻 2. Component Navbar
Tạo frontend/app/(user)/component/navbar.js:
"use client"
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import { useSelector } from "react-redux";
export default function Navbar() {
const pathname = usePathname();
const cart = useSelector((state) => state.cart);
const totalitem = useMemo(() => {
return cart.reduce((total, item) => total + item.quantity, 0);
}, [cart]);
return (
<nav className="navbar navbar-expand-lg bg-dark pt-10" data-bs-theme="dark">
<div className="container-fluid">
<a className="navbar-brand offset-md-1" href="/">
<i className="fa-solid fa-spray-can-sparkles"></i> Lith Perfume
</a>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarSupportedContent">
{/* Menu bên trái */}
<ul className="navbar-nav me-auto mb-2 mb-lg-0">
<li className="nav-item">
<Link className={`nav-link ${pathname === "/" ? "active" : ""}`} href="/">
Trang Chủ
</Link>
</li>
<li className="nav-item">
<Link className={`nav-link ${pathname.startsWith("/menu") ? "active" : ""}`} href="/menu">
Menu
</Link>
</li>
</ul>
{/* Ô tìm kiếm (bài 21 sẽ làm cho hoạt động) */}
<form className="d-flex" role="search">
<input
className="form-control"
type="search"
placeholder="Tìm kiếm ?"
aria-label="Search"
/>
</form>
{/* Menu bên phải */}
<ul className="navbar-nav ms-auto mb-2 mb-lg-0">
<li className="nav-item d-flex">
<Link className="nav-link" href="/cart">
Giỏ hàng <span className="badge bg-danger">{totalitem}</span>
</Link>
</li>
<li className="nav-item dropdown">
<a
className="nav-link dropdown-toggle"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Tài Khoản
</a>
<ul className="dropdown-menu dropdown-menu-end">
<li><Link className="dropdown-item" href="/login">Đăng Nhập</Link></li>
<li><Link className="dropdown-item" href="/register">Đăng Kí</Link></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
);
}
🔍 Giải thích
"use client" — cần vì dùng usePathname, useSelector, useMemo.
useSelector((state) => state.cart) — đọc mảng giỏ hàng từ Redux store (bài 13).
useMemo — chỉ tính lại tổng số lượng khi cart thay đổi.
data-bs-toggle / data-bs-target — thuộc tính của Bootstrap JavaScript.
Chúng chỉ hoạt động nếu bootstrap.bundle.min.js đã được nạp (bài 10).
⚠️ Ba lỗi đã sửa so với source gốc
| Lỗi gốc | Đã sửa thành | Lý do |
|---|---|---|
export default function navbar() |
Navbar() |
Component phải viết hoa |
class="navbar-brand", class="fa-solid..." |
className= |
JSX không dùng class |
<ul className="dropdown-menu"><Link .../></ul> |
Bọc <Link> trong <li> |
HTML hợp lệ: <ul> chỉ chứa <li> |
pathname == "/menu" chỉ khớp đúng /menu |
pathname.startsWith("/menu") |
Để /menu/123 cũng được highlight |
<i class="fa-solid fa-mug-hot"> (cốc cà phê) |
fa-spray-can-sparkles (chai xịt) |
Icon phù hợp với nước hoa |
💻 3. Component Slider
Tạo frontend/app/(user)/component/slider.js:
export default function Slider() {
return (
<div id="carouselExample" className="carousel slide" data-bs-ride="carousel">
<div className="carousel-inner">
<div className="carousel-item active">
<img src="/img/ban1.jpg" className="d-block w-100" alt="Banner 1" />
</div>
<div className="carousel-item">
<img src="/img/ban2.jpg" className="d-block w-100" alt="Banner 2" />
</div>
<div className="carousel-item">
<img src="/img/ban3.jpg" className="d-block w-100" alt="Banner 3" />
</div>
</div>
<button
className="carousel-control-prev"
type="button"
data-bs-target="#carouselExample"
data-bs-slide="prev"
>
<span className="carousel-control-prev-icon" aria-hidden="true"></span>
<span className="visually-hidden">Previous</span>
</button>
<button
className="carousel-control-next"
type="button"
data-bs-target="#carouselExample"
data-bs-slide="next"
>
<span className="carousel-control-next-icon" aria-hidden="true"></span>
<span className="visually-hidden">Next</span>
</button>
</div>
);
}
🔍 Ghi chú
- Component này không cần
"use client"— chỉ có HTML tĩnh, không hook, không sự kiện. Bootstrap JS tự gắn hành vi vào sau khi trang tải xong. data-bs-ride="carousel"— tự động chạy slide (source gốc thiếu thuộc tính này nên phải bấm nút mới chuyển ảnh).id="carouselExample"phải khớp vớidata-bs-target="#carouselExample"ở 2 nút.- Ảnh nằm ở
frontend/public/img/ban1.jpg→ đường dẫn/img/ban1.jpg. - Source gốc viết
class="carousel slide"— đã sửa thànhclassName.
💻 4. Component Product — thẻ sản phẩm
Tạo frontend/app/(user)/component/product.js:
import Link from "next/link";
import AddCardbutton from "./addcardbuton";
export default function Product(props) {
const product = props.data;
return (
<div className="card mb-2 h-100">
<Link href={`/product/${product._id}`}>
<img
src={`${process.env.NEXT_PUBLIC_IMA_URL}${product.image}`}
className="card-img-top"
alt={product.name}
/>
</Link>
<div className="card-body d-flex flex-column">
<Link className="text-dark text-decoration-none" href={`/product/${product._id}`}>
<h5 className="card-title">{product.name}</h5>
</Link>
{product.rating > 0 && (
<small className="text-warning fw-bold me-1">
{product.rating} <i className="fa-solid fa-star"></i>
</small>
)}
<p className="card-text fw-bold">{product.price.toLocaleString("vi-VN")}đ</p>
<div className="mt-auto">
<AddCardbutton product={product} quantity={1} size="s">
Đặt Mua
</AddCardbutton>
</div>
</div>
</div>
);
}
🔍 Giải thích
process.env.NEXT_PUBLIC_IMA_URL — biến môi trường trỏ tới backend.
Sẽ cấu hình ở bài 12. Giá trị: http://localhost:5000/img/
Ghép lại thành: http://localhost:5000/img/sp1.jpg
h-100 + d-flex flex-column + mt-auto — kỹ thuật làm mọi thẻ cao bằng nhau
và nút "Đặt Mua" luôn nằm ở đáy dù tên sản phẩm dài ngắn khác nhau.
Source gốc dùng style={{ width: 18 + "rem" }} — chiều rộng cố định, không co giãn
theo lưới nên hiển thị lệch trên màn hình nhỏ.
.toLocaleString("vi-VN") — 5050000 → 5.050.000.
Source gốc chỉ viết {product.price}đ → hiện 5050000đ, khó đọc.
{product.rating > 0 && ...} — chỉ hiện sao khi có đánh giá.
💻 5. Component AddCardbutton
Tạo frontend/app/(user)/component/addcardbuton.js:
"use client"
import { addItem } from "@/redux/slices/cartSlicer";
import { useDispatch } from "react-redux";
export default function AddCardbutton({ product, quantity, size, className, children }) {
const dispatch = useDispatch();
return (
<button
onClick={() => dispatch(addItem({ product, quantity, size }))}
className={`btn btn-dark w-100 ${className || ""}`}
>
{children}
</button>
);
}
⚠️ So với source gốc
// GỐC — bỏ quên children, hardcode chữ "đặt mua"
export default function AddCardbutton(props) {
const dispatch = useDispatch();
const { product, quantity, size, className } = props;
return (
<button onClick={...} className={`btn btn-dark w-100 ${className}`}>
đặt mua
</button>
);
}
Ba vấn đề:
- Không dùng
children→ mọi nút đều hiện "đặt mua" dù truyền chữ khác ${className}khiclassNamelàundefined→ class trở thành"btn btn-dark w-100 undefined"- Tên file
addcardbutonsai chính tả (đúng:addcartbutton). Giữ nguyên để đối chiếu.
Bản trên đã sửa cả 3 (dùng className || "" để tránh chữ undefined).
💡 Ở trang chi tiết sản phẩm, source gốc có 2 nút thêm giỏ hàng liền nhau:
<AddCardbutton ...>thêm vào giỏ hàng</AddCardbutton> <button onClick={() => dispatch(addItem({product, quantity, size}))}>Thêm Vào Giỏ Hàng</button>Nút thứ hai là code cũ trước khi tách component, quên xóa. Hãy xóa nó đi.
💻 6. Component Productlist — lưới sản phẩm
Tạo frontend/app/(user)/component/productlist.js.
Phiên bản tạm thời (chưa có sort/filter — bài 15 sẽ hoàn thiện):
import Product from "./product";
export default function Productlist(props) {
const data = props.data || [];
if (data.length === 0) {
return <p className="text-center text-muted my-5">Không có sản phẩm nào</p>;
}
return (
<div className="container">
<div className="row row-cols-lg-4 row-cols-md-3 row-cols-2 g-3">
{data.map((item) => (
<div key={item._id} className="col">
<Product data={item} />
</div>
))}
</div>
</div>
);
}
⚠️ Ghi chú về source gốc
Source gốc có 3 vấn đề ở component này (sẽ xử lý ở bài 15):
export default function Productlist(props) {
let data = props.data;
const sortType = useSelector((state) => state.sort);
let defaultData = useRef([...props.data]); // ⚠️ Bug 1
const { min, max } = useSelector((state) => state.filter);
data = data.filter((item) => min <= item.price && item.price <= max);
if (sortType === 'ASC') {
data.sort((a, b) => a.price - b.price); // ⚠️ Bug 2
} else if (sortType === 'DESC') {
data.sort((a, b) => b.price - a.price);
} else {
data = defaultData.current; // ⚠️ Bug 3
}
// ...
}
| # | Bug | Hậu quả |
|---|---|---|
| 1 | useRef chỉ chụp dữ liệu ở lần render đầu |
Chuyển danh mục xong bấm "Mặc Định" → hiện sản phẩm danh mục cũ |
| 2 | .sort() sửa trực tiếp mảng |
Biến đổi mảng đến từ SWR cache |
| 3 | Nhánh "mặc định" bỏ qua kết quả filter |
Lọc theo giá xong bấm "Mặc Định" → bộ lọc mất tác dụng |
Ngoài ra còn dòng thừa không dùng:
const { _id, name, image, price } = item; // khai báo rồi không dùng gì cả
Bài 15 sẽ viết lại hoàn chỉnh cả 3 lỗi. Tạm thời dùng bản đơn giản ở trên.
💻 7. Trang chủ
Tạo frontend/app/(user)/page.js (bản đầy đủ sẽ ở bài 12 khi có SWR):
import Slider from "./component/slider";
import Link from "next/link";
const BRANDS = [
{ img: "h1.png", name: "Jean Paul" },
{ img: "h2.png", name: "VERSACE" },
{ img: "h3.png", name: "VALENTINO" },
{ img: "h4.png", name: "Ralph Lauren" },
{ img: "h5.png", name: "Chloé" },
{ img: "h6.png", name: "BURBERRY" },
{ img: "h7.png", name: "NARCISO RODRIGUEZ" },
{ img: "h8.png", name: "GUCCI" },
{ img: "h9.png", name: "TOM FORD" },
];
export default function Home() {
return (
<>
<Slider />
<div className="d-flex justify-content-center flex-wrap my-5 gap-3">
{BRANDS.map((brand) => (
<Link
key={brand.img}
href="/menu"
className="text-center text-decoration-none text-dark fw-bold"
style={{ width: "100px" }}
>
<img style={{ width: "60px" }} src={`/img/${brand.img}`} alt={brand.name} />
<br />
{brand.name}
</Link>
))}
</div>
<h2 className="text-center">SẢN PHẨM NỔI BẬT</h2>
{/* Bài 12 sẽ thêm <Productlist /> ở đây */}
</>
);
}
⚠️ So với source gốc
Source gốc viết lặp lại 9 lần khối <Link> cho 9 thương hiệu:
<Link href="/" className="text-center text-decoration-none text-dark fw-bold">
<img style={{ width: 60 + "px" }} src="/img/h1.png"></img> <br />
Jean Paul
</Link>
<Link href="/" className="text-center text-decoration-none text-dark fw-bold">
<img style={{ width: 60 + "px" }} src="/img/h2.png"></img><br />
SERSACE
</Link>
{/* ... 7 lần nữa */}
Ba vấn đề:
- Lặp code — sửa style phải sửa 9 chỗ. Bản trên dùng mảng +
.map() - Mọi link đều
href="/"— bấm vào thương hiệu nào cũng về trang chủ. Đúng ra phải trỏ tới/menu/<categoryId> - Sai chính tả:
SERSACE(đúng: VERSACE),Chloẻ(đúng: Chloé)
Bài tập cuối bài sẽ hướng dẫn nối link tới đúng danh mục.
📚 8. Font Awesome — icon
Dự án dùng Font Awesome qua 2 cách:
Cách 1 — CDN kit (khu (user)):
<script src="https://kit.fontawesome.com/7b8b40ab0a.js" crossOrigin="anonymous"></script>
Cách 2 — File CSS cục bộ (khu admin):
import "@/public/css/fontawesome.css";
Cách dùng icon:
<i className="fa-solid fa-star"></i> {/* sao đầy */}
<i className="fa-regular fa-star"></i> {/* sao rỗng */}
<i className="fa-solid fa-trash"></i> {/* thùng rác */}
⚠️ Kit ID
7b8b40ab0alà của tài khoản người viết dự án gốc. Nó có thể bị giới hạn domain hoặc bị xóa bất cứ lúc nào → icon biến mất hết.Nên làm: tự đăng ký kit riêng tại https://fontawesome.com, hoặc dùng bản CDN công khai:
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />⚠️ Icon dạng
fa-light/fal(dùng nhiều trong khu admin) là bản trả phí. Chúng sẽ không hiển thị với tài khoản miễn phí. Đổi sangfa-solid/fas.
✅ 9. Kiểm thử
Sau khi tạo xong các component, cập nhật layout.js để dùng Navbar
(tạm bỏ <Providers> vì chưa có Redux — bài 13 sẽ thêm):
import { Inter } from "next/font/google";
import "./globals.css";
import "@/public/css/bootstrap.min.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata = { title: "Lith Perfume" };
export default function RootLayout({ children }) {
return (
<html lang="vi">
<body className={inter.className}>
{children}
<script src="https://kit.fontawesome.com/7b8b40ab0a.js" crossOrigin="anonymous"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
</body>
</html>
);
}
Chạy npm run dev và kiểm tra:
- [ ] Trang chủ hiện carousel, ảnh tự chuyển sau vài giây
- [ ] Bấm nút mũi tên trái/phải chuyển được ảnh
- [ ] 9 logo thương hiệu hiện thành hàng ngang, tự xuống dòng khi thu nhỏ cửa sổ
- [ ] Console (F12) không có warning
Invalid DOM property 'class'
💡 Navbar chưa dùng được vì cần Redux. Bài 13 sẽ hoàn thiện.
📝 Bài tập
-
Nối logo thương hiệu tới đúng danh mục. Đổi mảng
BRANDSthành có thêmid:{ img: "h8.png", name: "GUCCI", id: "669a978e6e282b058a8e3f2c" }Rồi đổi
href="/menu"thànhhref={/menu/${brand.id}}. Nâng cao: thay vì hardcode, gọi API/categoriesvà render động từ dữ liệu thật. -
Thêm hiệu ứng hover cho thẻ sản phẩm. Trong
globals.css:.card:hover { transform: translateY(-4px); box-shadow: 0 8px 20px rgba(0, 0, 0, .12); transition: all .25s ease; } -
Thêm badge "Mới" hoặc "Hot" cho sản phẩm có
rating >= 4.5. Gợi ý:position-absolute top-0 end-0 badge bg-danger m-2. -
Footer: tạo
component/footer.jsvới thông tin liên hệ, mạng xã hội, rồi thêm vàolayout.jssau{children}. -
Kiểm tra responsive: mở DevTools (F12) → biểu tượng điện thoại → thử các kích thước 375px, 768px, 1440px. Sửa lại lưới nếu thấy vỡ.
-
Đếm warning: mở Console, đếm số warning
Invalid DOM property. Sửa hếtclass=thànhclassName=trong toàn bộ dự án. Mục tiêu: 0 warning.
⬅️ Bài trước | Mục lục | Bài tiếp theo: Fetch dữ liệu với SWR ➡️
All rights reserved