hoc-lap-trinh-2

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;

By hoadv