Github: https://github.com/vanhoa690/web209-react
Cài đặt Project:
npm create vite@latest
Đặt tên project Name -> Chọn React -> Chọn Typescript
cd Project_name
npm i
npm run dev
Cài đặt MUI + axios + react-router-dom
npm i @mui/material @emotion/react @emotion/styled @mui/icons-material
npm i axios react-router-dom
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
}
}
]
}
types/Product.ts
export type Product = {
id: string;
title: string;
price: number;
image: string;
description: string;
category: string;
rating: {
rate: number;
count: number;
};
};
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>
);
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 <main>{routes}</main>;
}
export default App;
components/ProductCard.tsx
Truyền type Props trong Function Component
https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/function_components
import { FC } from "react";
import {
Button,
Card,
CardActions,
CardContent,
CardMedia,
Typography,
} from "@mui/material";
import { Product } from "../types/Product";
type ProductCardProps = {
product: Product;
};
const ProductCard: FC<ProductCardProps> = ({ product }) => {
return (
<Card sx={{ maxWidth: 345 }}>
<CardMedia
component="img"
alt="green iguana"
height="140"
image={product.image}
sx={{ objectFit: "contain" }}
/>
<CardContent>
<Typography gutterBottom variant="h5" component="div">
{product.title}
</Typography>
<Typography variant="body2" color="text.secondary">
Lizards are a widespread group of squamate reptiles, with over 6,000
species, ranging across all continents except Antarctica
</Typography>
</CardContent>
<CardActions>
<Button size="small">Share</Button>
<Button size="small">Learn More</Button>
</CardActions>
</Card>
);
};
export default ProductCard;
components/Loading.tsx
import { FC } from "react";
import { Box, LinearProgress } from "@mui/material";
type LoadingProps = {
isShow: boolean;
};
const Loading: FC<LoadingProps> = ({ isShow }) => {
return (
<>
{isShow && (
<Box sx={{ width: "100%" }}>
<LinearProgress />
</Box>
)}
</>
);
};
export default Loading;
Config import from src/*
tsconfig.app.json
"compilerOptions": {
...
"baseUrl": ".",
"paths": {
"src/*": ["./src/*"]
}
}
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
src: "/src",
},
},
});
Tạo folder pages: pages/ProductDetail.tsx
import axios from "axios";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Product } from "src/types/Product";
import { Button, Container, Stack, Typography } from "@mui/material";
import Loading from "src/components/Loading";
function ProductDetail() {
const { id } = useParams();
const [loading, setLoading] = useState<boolean>(false);
const [product, setProduct] = useState<Product | undefined>();
const getProduct = async (id: string) => {
try {
setLoading(true);
const { data } = await axios.get(`http://localhost:3000/products/${id}`);
setProduct(data);
} catch (error) {
console.log(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!id) return;
getProduct(id);
}, [id]);
return (
<>
<Loading isShow={loading} />
<Container>
{product && (
<Stack direction={"row"} gap={3}>
<img src={product.image} alt="" width={"500px"} />
<Stack gap={3}>
<Typography component="h1" fontSize={"26px"}>
{product.title}
</Typography>
<Typography fontWeight={"bold"} color={"Highlight"}>
${product.price}
</Typography>
<Typography fontSize={"20px"}>
Rate: {product.rating.count}
</Typography>
<Typography>{product.description}</Typography>
<Button variant="outlined">Add to cart</Button>
</Stack>
</Stack>
)}
</Container>
</>
);
}
export default ProductDetail;
Tạo file pages/Homepage.tsx
import { Stack } from "@mui/material";
import axios from "axios";
import { useEffect, useState } from "react";
import Loading from "src/components/Loading";
import ProductCard from "src/components/ProductCard";
import { Product } from "src/types/Product";
function Homepage() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const getAllProduct = async () => {
try {
setLoading(true);
const { data } = await axios.get("http://localhost:3000/products");
setProducts(data);
} catch (error) {
console.log(error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getAllProduct();
}, []);
return (
<>
<Loading isShow={loading} />
<Stack
direction={"row"}
flexWrap={"wrap"}
gap={2}
alignItems={"center"}
justifyContent={"center"}
>
{products.map((product, index) => (
<ProductCard key={index} product={product} />
))}
</Stack>
</>
);
}
export default Homepage;
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 { Button, Container, Stack, TextField, Typography } from "@mui/material";
import axios from "axios";
import { useForm, SubmitHandler } from "react-hook-form";
type RegisterFormParams = {
username: string;
email: string;
password: string;
};
const Register = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormParams>();
const onSubmit: SubmitHandler<RegisterFormParams> = async (data) => {
try {
await axios.post("http://localhost:3000/register", data);
} catch (error) {}
};
return (
<Container>
<Typography variant="h2" textAlign={"center"} mb={2}>
Register
</Typography>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap={2}>
<TextField
label="Username"
{...register("username", {
required: "Username is required",
})}
error={!!errors?.username?.message}
helperText={errors?.username?.message}
/>
<TextField
label="Email"
{...register("email", {
required: "Email is required",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "invalid email address",
},
})}
error={!!errors?.email?.message}
helperText={errors?.email?.message}
/>
<TextField
label="Password"
{...register("password", {
required: "Password is required",
minLength: {
value: 6,
message: "Password is min length 6 characters",
},
})}
type="password"
error={!!errors?.password?.message}
helperText={errors?.password?.message}
/>
<Button type="submit" variant="contained">
Submit
</Button>
</Stack>
</form>
</Container>
);
};
export default Register;
import { Button, Container, Stack, TextField, Typography } from "@mui/material";
import axios from "axios";
import { useForm, SubmitHandler } from "react-hook-form";
import { useNavigate } from "react-router-dom";
type LoginFormParams = {
username: string;
email: string;
password: string;
};
const Login = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormParams>();
const navigate = useNavigate();
const onSubmit: SubmitHandler<LoginFormParams> = async (data) => {
try {
const res = await axios.post("http://localhost:3000/login", data);
localStorage.setItem("token", res.data.accessToken);
navigate("/admin");
} catch (error) {}
};
return (
<Container>
<Typography variant="h2" textAlign={"center"} mb={2}>
Login
</Typography>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap={2}>
<TextField
label="Email"
{...register("email", {
required: "Email is required",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "invalid email address",
},
})}
error={!!errors?.email?.message}
helperText={errors?.email?.message}
/>
<TextField
label="Password"
{...register("password", {
required: "Password is required",
minLength: {
value: 6,
message: "Password is min length 6 characters",
},
})}
type="password"
error={!!errors?.password?.message}
helperText={errors?.password?.message}
/>
<Button type="submit" variant="contained">
Submit
</Button>
</Stack>
</form>
</Container>
);
};
export default Login;
import { useEffect } from "react";
import { Outlet, useNavigate } from "react-router-dom";
const LayoutAdmin = () => {
const navigate = useNavigate();
const token = localStorage.getItem("token");
useEffect(() => {
if (!token) {
nav("/login");
return;
}
}, [token, nav]);
return (
<>
<p>Sidebar</p>
<Outlet />
</>
);
};
export default LayoutAdmin;
import { useRoutes } from "react-router-dom";
import Homepage from "./pages/Homepage";
import ProductDetail from "./pages/ProductDetail";
import Register from "./pages/Register";
import LayoutClient from "./layouts/LayoutClient";
import Login from "./pages/Login";
import LayoutAdmin from "./layouts/LayoutAdmin";
import Admin from "./pages/admin";
const routeConfig = [
{
path: "admin",
element: <LayoutAdmin />,
children: [
{
path: "",
element: <Admin />,
},
],
},
{
path: "/",
element: <LayoutClient />,
children: [
{ path: "/", element: <Homepage /> },
{ path: "product/:id", element: <ProductDetail /> },
{
path: "/register",
element: <Register />,
},
{
path: "/login",
element: <Login />,
},
],
},
];
function App() {
const routes = useRoutes(routeConfig);
return <main>{routes}</main>;
}
export default App;
import { createContext, ReactNode, useContext, useState } from "react";
import { User } from "src/types/User";
type UserContextType = {
user: User | null;
setUser: (user: User) => void;
};
// 1 create context
const UserContext = createContext<UserContextType | undefined>(undefined);
// 2. useContext: useUser : custorm hook
export const useUser = (): UserContextType => {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error("useUser must be used within a UserProvider");
}
return context;
};
// provider
export const UserProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [user, setUser] = useState<User | null>(null);
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
};
import axios, { AxiosError } from "axios";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useLoading } from "src/context/loading";
import { Product, ProductInputs } from "src/types/Product";
export const useProduct = () => {
const nav = useNavigate();
const { id } = useParams();
const [products, setProducts] = useState<Product[]>([]);
const [product, setProduct] = useState<Product | undefined>();
const [error, setError] = useState<string>("");
const { setLoading } = useLoading();
const getAllProduct = useCallback(async () => {
try {
setLoading(true);
const { data } = await axios.get("/products");
setProducts(data);
} catch (error) {
setError((error as AxiosError)?.message);
} finally {
setLoading(false);
}
}, []);
const totalProduct = useMemo(() => products.length, [products]);
useEffect(() => {
getAllProduct();
}, []);
useEffect(() => {
if (!id) return;
getProductDetail(id);
}, [id]);
const handleDeleteProduct = useCallback(async (id: string) => {
if (window.confirm("Xoa that ko?")) {
try {
setLoading(true);
await axios.delete(`/products/${id}`);
getAllProduct();
} catch (error) {
setError((error as AxiosError)?.message);
} finally {
setLoading(false);
}
}
}, []);
// 3 function
const getProductDetail = async (id: string) => {
try {
setLoading(true);
const { data } = await axios.get(`/products/${id}`);
setProduct(data);
} catch (error) {
setError((error as AxiosError)?.message);
} finally {
setLoading(false);
}
};
const handleAddProduct = async (data: ProductInputs) => {
try {
setLoading(true);
await axios.post("products", data);
alert("OK");
nav("/admin/product/list");
} catch (error) {
setError((error as AxiosError)?.message);
} finally {
setLoading(false);
}
};
const handleEditProduct = async (data: ProductInputs) => {
try {
setLoading(true);
await axios.put(`/products/${id}`, data);
alert("OK");
nav("/admin/product/list");
} catch (error) {
setError((error as AxiosError)?.message);
} finally {
setLoading(false);
}
};
return {
error,
products,
product,
totalProduct,
getAllProduct,
handleDeleteProduct,
handleAddProduct,
handleEditProduct,
};
};
import {
Alert,
Container,
IconButton,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import { useProduct } from "src/hooks/useProduct";
function AdminProductList() {
const { error, products, totalProduct, handleDeleteProduct } = useProduct();
return (
<Container>
{error && <Alert severity="error">{error}</Alert>}
<Typography variant="h3">
Admin Product List - {totalProduct} Products
</Typography>
;
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Title</TableCell>
<TableCell align="right">Image</TableCell>
<TableCell align="right">Price</TableCell>
<TableCell align="right">Desc</TableCell>
<TableCell align="right">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{products.map((product, index) => (
<TableRow
key={index}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell component="th" scope="row">
{product.title}
</TableCell>
<TableCell align="right">
<img src={product.image} width={"100px"} />
</TableCell>
<TableCell align="right">{product.price}</TableCell>
<TableCell align="right">Desc</TableCell>
<TableCell align="right">
<Stack gap={3} direction={"row"}>
<IconButton>
<EditIcon />
</IconButton>
<IconButton onClick={() => handleDeleteProduct(product.id)}>
<DeleteIcon sx={{ color: "red" }} />
</IconButton>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Container>
);
}
export default AdminProductList;
import {
Button,
Checkbox,
Container,
FormControl,
FormControlLabel,
FormLabel,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
} from "@mui/material";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { Product, ProductInputs } from "src/types/Product";
type ProductFormProps = {
product?: Product;
onSubmit: (value: ProductInputs) => void;
};
function ProductForm({ onSubmit, product }: ProductFormProps) {
const {
register,
handleSubmit,
control,
reset,
formState: { errors },
} = useForm<ProductInputs>();
useEffect(() => {
if (!product) return;
reset(product);
}, [product]);
return (
<Container>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap={3}>
<FormControl>
<FormLabel>Title</FormLabel>
<TextField
{...register("title", { required: "Title is Required" })}
error={!!errors?.title}
helperText={errors?.title && errors.title.message}
/>
</FormControl>
<FormControl>
<FormLabel>Image</FormLabel>
<TextField {...register("image")} />
</FormControl>
<FormControl>
<FormLabel>Price</FormLabel>
<TextField {...register("price")} type="number" />
</FormControl>
<FormControl>
<FormLabel>Description</FormLabel>
<TextField multiline rows={4} {...register("description")} />
</FormControl>
<FormControlLabel
control={
<Controller
name="isShowProduct"
control={control}
render={({ field }) => (
<Checkbox {...field} checked={field.value || false} />
)}
/>
}
label="Show Product"
/>
<FormControl variant="standard">
<InputLabel>Category</InputLabel>
<Controller
name="category"
control={control}
defaultValue={product?.category || ""}
render={({ field }) => (
<Select {...field}>
<MenuItem value={"1"}>Hp</MenuItem>
<MenuItem value={"2"}>Apple</MenuItem>
<MenuItem value={"3"}>Dell</MenuItem>
</Select>
)}
/>
</FormControl>
<Button variant="contained" type="submit">
Submit
</Button>
</Stack>
</form>
</Container>
);
}
export default ProductForm;