Github: https://github.com/vanhoa690/web502-typescript
ASM 1: Hoàn thiện giao diện chức năng các page sau
- Homepage – 1.5 điểm
- Product Detail – 1.5 điểm
- Register: username, email, password, confirm password: – 1.5 điểm
- Login: email, password, validate email, password min 6 ký tự: – 1.5 điểm
- Giao diện UI đẹp mắt : – 1 điểm
- Bảo vệ routes admin: – 1 điểm
- Config axios chèn token vào header khi call API: – 1 điểm
- Xử lý thông báo Error, Loading khi call API Hoặc API lỗi: – 1 điểm
Lab 3 + Lab 4: Xây dựng Giao diện Trang Register + Login (react-hook-form)
- Trang Register (/register): có username, email, password, confirmPassword, validate (required, email) + show error – chuyển trang login và show thông báo thành công – 3 điểm
- Trang Login (/login): lưu token vào localStorage + email + password , validate (required, email) + show error – chuyển trang Homepage và show thông báo thành công – 3 điểm
- Có show lỗi khi call API Error – 2 điểm
- Định nghĩa type/interface User đầy đủ theo response API trả về – 1 điểm
- Giao diện UI đẹp mắt – 1 điểm
Lab 1 + Lab 2: Xây dựng Giao diện Trang Homepage + Product Detail
- Trang Hompage (/): có header, footer, danh sách sản phẩm, click vào product sang trang chi tiết sản phẩm – 3 điểm
- Trang ProductDetail (/product/:id): có header, footer, có thông tin chi tiết sản phẩm (tiêu đề, mô tả, giá cả, đánh giá, category …) – 3 điểm
- Có show lỗi khi call API Error và khi id product không hợp lệ hoặc không tìm thì hiện thị thông tin Product Not Found (hoặc chuyển trang Not Found) – 2 điểm
- Định nghĩa type/interface Product đầy đủ theo response API trả về – 1 điểm
- Giao diện UI đẹp mắt – 1 điểm
Create Project React + Typescript
npm create vite@latest
Đặt tên project_name
Chọn React
Chọn Typescript
cd project_name
npm i
npm i axios react-router-dom
npm run dev
File package.json: Thêm script server để chạy API: npm run server
"scripts": {
"start": "vite --open",
"server": "npx json-server db.json",
"dev": "vite",
...
}
Tạo file db.json để chạy API
{
"products": [
{
"id": 1,
"title": "Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops",
"price": 109.95,
"description": "Your perfect pack for everyday use and walks in the forest. Stash your laptop (up to 15 inches) in the padded sleeve, your everyday",
"category": "men's clothing",
"image": "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg",
"rating": {
"rate": 3.9,
"count": 120
}
},
{
"id": 2,
"title": "Mens Casual Premium Slim Fit T-Shirts ",
"price": 22.3,
"description": "Slim-fitting style, contrast raglan long sleeve, three-button henley placket, light weight & soft fabric for breathable and comfortable wearing. And Solid stitched shirts with round neck made for durability and a great fit for casual fashion wear and diehard baseball fans. The Henley style round neckline includes a three-button placket.",
"category": "men's clothing",
"image": "https://fakestoreapi.com/img/71-3HjGNDUL._AC_SY879._SX._UX._SY._UY_.jpg",
"rating": {
"rate": 4.1,
"count": 259
}
}
]
}
Add routeConfig sử dụng useRoutes vào file App.tsx
import { useRoutes } from "react-router-dom";
import Homepage from "./pages/Homepage";
import ProductDetail from "./pages/ProductDetail";
const routeConfig = [
{ path: "/", element: <Homepage /> },
{ path: "/product/:id", element: <ProductDetail /> },
];
function App() {
const routes = useRoutes(routeConfig);
return <div>{routes}</div>;
}
export default App;
Thêm BrowserRouter vào file main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { BrowserRouter } from "react-router-dom";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
Tạo folder pages: tạo file pages/Homepage.tsx
import { useEffect, useState } from "react";
import axios from "axios";
type Product = {
id: string;
title: string;
image: string;
description: string;
};
function Homepage() {
const [products, setProducts] = useState<Product[]>([]);
const getAllProduct = async () => {
const { data } = await axios.get("http://localhost:3000/products");
setProducts(data);
};
useEffect(() => {
getAllProduct();
}, []);
return (
<>
{products.map((product, index) => (
<div key={index} className="card" style={{ width: "18rem" }}>
<img
src={product.image}
className="card-img-top"
alt="..."
width={"100px"}
/>
<div className="card-body">
<h5 className="card-title">{product.title}</h5>
<p className="card-text">{product.description}</p>
<a href={`/product/${product.id}`} className="btn btn-primary">
Detail
</a>
</div>
</div>
))}
</>
);
}
export default Homepage;
Folder types: types/Product.ts
export type Product = {
id: string;
title: string;
image: string;
description: string;
price: number;
category: string;
};
Tạo file pages/ProductDetail.tsx
import { useEffect, useState } from "react";
import axios from "axios";
import { useParams } from "react-router-dom";
import { Product } from "../types/Product";
function ProductDetail() {
const { id } = useParams();
const [product, setProduct] = useState<Product | undefined>();
const getProduct = async (id: string) => {
const { data } = await axios.get(`http://localhost:3000/products/${id}`);
setProduct(data);
};
useEffect(() => {
if (!id) return;
getProduct(id);
}, [id]);
return (
<>
{product && (
<div className="container">
<div className="d-flex">
<img src={product.image} width={"400px"} />
<div className="card-body">
<h5 className="card-title">Title: {product.title}</h5>
<p className="card-text">Mo ta: {product.description}</p>
<p className="card-text">Price: {product.price}</p>
<p className="card-text">Category: {product.category}</p>
</div>
</div>
</div>
)}
</>
);
}
export default ProductDetail;
Cài đặt thư viện json-server, json-server-auth
Thêm users trong db.json
{
"products": [],
"users": []
}
Thêm script: server (npm run server)
"scripts": {
"start": "vite --open",
"server": "json-server --watch db.json -m ./node_modules/json-server-auth",
...
}
"dependencies": {
...
"json-server": "^0.17.4",
"json-server-auth": "^2.1.0",
...
}
Tạo file pages/Register.tsx
import axios from "axios";
import { useForm, SubmitHandler } from "react-hook-form";
type Inputs = {
username: string;
email: string;
password: string;
};
function Register() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Inputs>();
const onSubmit: SubmitHandler<Inputs> = async (data) => {
try {
await axios.post("http://localhost:3000/register", data);
} catch (error) {}
};
return (
<div className="container mt-4">
<h1 className="text-center">Register User</h1>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
type="text"
className="form-control"
id="username"
aria-describedby="emailHelp"
placeholder="Enter username"
{...register("username", {
required: "Username is required",
})}
/>
{errors?.username && (
<small id="emailHelp" className="text-danger">
{errors?.username?.message}
</small>
)}
</div>
<div className="form-group">
<label htmlFor="exampleInputEmail1">Email address</label>
<input
type="text"
className="form-control"
id="exampleInputEmail1"
aria-describedby="emailHelp"
placeholder="Enter email"
{...register("email", {
required: "Email is required",
})}
/>
{errors?.email && (
<small id="emailHelp" className="text-danger">
{errors?.email?.message}
</small>
)}
</div>
<div className="form-group">
<label htmlFor="exampleInputPassword1">Password</label>
<input
type="password"
className="form-control"
id="exampleInputPassword1"
placeholder="Password"
{...register("password", {
required: "Password is required",
})}
/>
{errors?.password && (
<small id="emailHelp" className="text-danger">
{errors?.password?.message}
</small>
)}
</div>
<button type="submit" className="btn btn-primary">
Submit
</button>
</form>
</div>
);
}
export default Register;
Tạo file pages/Login.tsx
import axios from "axios";
import { useForm, SubmitHandler } from "react-hook-form";
type Inputs = {
email: string;
password: string;
};
function Login() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Inputs>();
const onSubmit: SubmitHandler<Inputs> = async (data) => {
console.log(data);
const res = await axios.post("http://localhost:3000/login", data);
localStorage.setItem("token", res.data.accessToken);
};
return (
<div className="container mt-4">
<h1 className="text-center">Login User</h1>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="form-group">
<label htmlFor="exampleInputEmail1">Email address</label>
<input
type="text"
className="form-control"
id="exampleInputEmail1"
{...register("email", {
required: "Email is required",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "invalid email address",
},
})}
/>
{errors?.email && (
<small id="emailHelp" className="text-danger">
{errors?.email?.message}
</small>
)}
</div>
<div className="form-group">
<label htmlFor="exampleInputPassword1">Password</label>
<input
type="password"
className="form-control"
id="exampleInputPassword1"
placeholder="Password"
{...register("password", {
required: "Password is required",
minLength: {
value: 6,
message: "Password is min length 6 characters",
},
})}
/>
{errors?.password && (
<small id="emailHelp" className="text-danger">
{errors?.password?.message}
</small>
)}
</div>
<button type="submit" className="btn btn-primary">
Submit
</button>
</form>
</div>
);
}
export default Login;
Tạo file layouts/LayoutAdmin.tsx
import { Navigate, Outlet } from "react-router-dom";
function LayoutAdmin() {
const token = localStorage.getItem("token");
return (
<>
{token ? (
<>
<p>Sidebar</p>
<Outlet />
</>
) : (
<Navigate to={"/login"} />
)}
</>
);
}
export default LayoutAdmin;
File App.tsx
import { useRoutes } from "react-router-dom";
import Homepage from "./pages/Homepage";
import ProductDetail from "./pages/ProductDetail";
import NotFound from "./pages/NotFound";
import Register from "./pages/Register";
import Login from "./pages/Login";
import Admin from "./pages/admin";
import LayoutAdmin from "./layouts/LayoutAdmin";
import Dashboard from "./pages/admin/Dashboard";
import AdminProductList from "./pages/admin/product/List";
import LayoutClient from "./layouts/LayoutClient";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
const routeConfig = [
{
path: "",
element: <LayoutClient />,
children: [
{ path: "", element: <Homepage /> },
{ path: "/product/:id", element: <ProductDetail /> },
{ path: "/register", element: <Register /> },
{ path: "/login", element: <Login /> },
],
},
{
path: "/admin",
element: <LayoutAdmin />,
children: [
{
path: "",
element: <Admin />,
},
{
path: "dashboard",
element: <Dashboard />,
},
{
path: "product/list",
element: <AdminProductList />,
},
],
},
{ path: "*", element: <NotFound /> },
];
function App() {
const routes = useRoutes(routeConfig);
return (
<div>
<ToastContainer />
{routes}
</div>
);
}
export default App;
File pages/admin/product/List.tsx
import { useEffect, useState } from "react";
import { Product } from "../../../types/Product";
import axios from "axios";
import Loading from "../../../components/Loading";
import { toast } from "react-toastify";
type Error = {
message: string;
};
function AdminProductList() {
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setLoading] = useState<boolean>(false);
const getAllProduct = async () => {
try {
setLoading(true);
const { data } = await axios.get("http://localhost:3000/products");
setProducts(data);
} catch (error) {
toast.error((error as Error)?.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
getAllProduct();
}, []);
const handleDeleteProduct = async (id: string) => {
if (window.confirm("Xoa that ko?")) {
try {
setLoading(true);
await axios.delete(`http://localhost:3000/products/${id}`);
toast.success("Xoa thanh cong !");
getAllProduct();
} catch (error) {
toast.error((error as Error)?.message);
} finally {
setLoading(false);
}
}
};
return (
<div className="container">
<Loading isShow={isLoading} />
<h1>AdminProductList</h1>
<table className="table">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Title</th>
<th scope="col">Price</th>
<th scope="col">Image</th>
<th scope="col">Desc</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{products.map((product, index) => (
<tr key={index}>
<th scope="row">{product.id}</th>
<th scope="col">{product.title}</th>
<th scope="col">{product.price}</th>
<th scope="col">
<img src={product.image} width={"100px"} />
</th>
<th scope="col">{product.description.substring(0, 60)}...</th>
<th scope="col">
<button
className="btn btn-danger"
onClick={() => handleDeleteProduct(product.id)}
>
Delete
</button>
</th>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default AdminProductList;
import axios from "axios";
import { SubmitHandler, useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
type Inputs = {
title: string;
price: number;
image: string;
desc: string;
category: string;
isShowProduct: boolean;
};
function AdminAddProduct() {
const nav = useNavigate();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Inputs>();
const handleAddProduct: SubmitHandler<Inputs> = async (data) => {
try {
await axios.post("/products", data);
toast.success("Add Product Success"); // alert
setTimeout(() => nav("/admin/product/list"), 3000);
} catch (error) {
toast.error("Loi Call API"); // alert
}
};
return (
<div className="container">
<h1>Add Product</h1>
<form onSubmit={handleSubmit(handleAddProduct)}>
{/* Label + Input text*/}
<div className="form-group">
<label htmlFor="title">Title</label>
<input
type="text"
className="form-control"
id="title"
placeholder="Product Title"
{...register("title", { required: "Title xxxx is Required" })}
/>
{errors?.title && (
<small className="text-danger">{errors.title.message}</small>
)}
</div>
{/* End Input text */}
<div className="form-group">
<label htmlFor="price">Price</label>
<input
type="number"
className="form-control"
id="price"
placeholder="Product Price"
{...register("price", {
required: "Price is Required",
min: {
value: 1,
message: "Price is min 1",
},
})}
/>
{errors?.price && (
<small className="text-danger">{errors.price.message}</small>
)}
</div>
<div className="form-group">
<label htmlFor="image">Image</label>
<input
type="text"
className="form-control"
id="image"
placeholder="Product Image"
{...register("image")}
/>
</div>
<div className="form-group">
<label htmlFor="Desc">Desc</label>
<textarea
className="form-control"
id="Desc"
rows={3}
defaultValue={""}
{...register("desc")}
/>
</div>
{/* Check box: Show Product */}
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="isShowProduct"
{...register("isShowProduct")}
/>
<label className="form-check-label" htmlFor="isShowProduct">
Show Product
</label>
</div>
{/* Category: Select box */}
<div className="form-group">
<label htmlFor="Category">Category</label>
<select
className="form-control"
id="Category"
{...register("category")}
>
<option value="1">Hp</option>
<option value="2">Aplle</option>
<option value="3">Dell</option>
</select>
</div>
<button className="btn btn-primary">Add Product</button>
</form>
</div>
);
}
export default AdminAddProduct;