0

# 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àng
  • Slider — carousel banner
  • Product — thẻ hiển thị 1 sản phẩm
  • Productlist — 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-sm
  • card, card-body, card-title, card-text, card-img-top, card-header
  • navbar, nav-link, dropdown, dropdown-menu, dropdown-item
  • table, form-control, form-select, form-label
  • badge, 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ới data-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ành className.

💻 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")50500005.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 đề:

  1. Không dùng children → mọi nút đều hiện "đặt mua" dù truyền chữ khác
  2. ${className} khi classNameundefined → class trở thành "btn btn-dark w-100 undefined"
  3. Tên file addcardbuton sai 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 đề:

  1. Lặp code — sửa style phải sửa 9 chỗ. Bản trên dùng mảng + .map()
  2. 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>
  3. 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 7b8b40ab0a là 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 sang fa-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

  1. Nối logo thương hiệu tới đúng danh mục. Đổi mảng BRANDS thành có thêm id:

    { img: "h8.png", name: "GUCCI", id: "669a978e6e282b058a8e3f2c" }
    

    Rồi đổi href="/menu" thành href={/menu/${brand.id}}. Nâng cao: thay vì hardcode, gọi API /categories và render động từ dữ liệu thật.

  2. 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;
    }
    
  3. 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.

  4. Footer: tạo component/footer.js với thông tin liên hệ, mạng xã hội, rồi thêm vào layout.js sau {children}.

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

  6. Đếm warning: mở Console, đếm số warning Invalid DOM property. Sửa hết class= thành className= 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

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í