upload
This commit is contained in:
7
.env
Normal file
7
.env
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
VITE_SV_HOST="http://localhost:3000"
|
||||||
|
VITE_JWT_TOKEN="phuocntbasdasasdasd"
|
||||||
|
|
||||||
|
VITE_CLOUDINARY_UPLOAD_PRESET="testpreset"
|
||||||
|
VITE_CLOUDINARY_CLOUD_NAME="dusw32tsq"
|
||||||
|
VITE_CLOUDINARY_API_SECRET="NeUuUfbjlZSSyq0Zf390LECRdcI"
|
||||||
|
VITE_CLOUDINARY_API_KEY="114544946391646"
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
23
README.md
Normal file
23
README.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
(Video)[https://rikkeieducation.sg.larksuite.com/minutes/obsgda1i95dal25649sa4rz3]
|
||||||
|
## Rikkei Store
|
||||||
|
## Bán máy tính & điện thoại
|
||||||
|
|
||||||
|
|
||||||
|
## ADMiN
|
||||||
|
- Route Admin X
|
||||||
|
- Đăng nhập Admin X
|
||||||
|
- Phân quyền admin X
|
||||||
|
- Layout admin -
|
||||||
|
- Quản lý người dùng
|
||||||
|
|
||||||
|
|
||||||
|
## USER
|
||||||
|
- Đăng ký / đăng nhập X
|
||||||
|
- Layout người dùng X
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Cloudinary
|
||||||
|
npm i cloudinary
|
||||||
|
|
||||||
|
## Update test
|
||||||
92
db.json
Normal file
92
db.json
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"displayName": "Nguyễn Phước",
|
||||||
|
"email": "admin@rikkei.com",
|
||||||
|
"password": "123",
|
||||||
|
"phoneNumber": "0123...",
|
||||||
|
"role": "ADMIN",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"userName": "admin",
|
||||||
|
"banReason": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1759281069504",
|
||||||
|
"userName": "admin2",
|
||||||
|
"password": "123",
|
||||||
|
"role": "USER",
|
||||||
|
"displayName": "Miêu Mèo",
|
||||||
|
"email": "phuocntb@mieusoft.com",
|
||||||
|
"phoneNumber": "906675349",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"banReason": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1759281453576",
|
||||||
|
"userName": "member",
|
||||||
|
"password": "123",
|
||||||
|
"role": "USER",
|
||||||
|
"displayName": "Người Dùng",
|
||||||
|
"email": "user@gmail.com",
|
||||||
|
"phoneNumber": "123456789",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"banReason": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "aa",
|
||||||
|
"title": "danh mục 1",
|
||||||
|
"iconUrl": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTLx0GLJkUHO42P1w4qBVzajal81_-N7oP-6w&s",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bb",
|
||||||
|
"title": "danh mục 2",
|
||||||
|
"iconUrl": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTLx0GLJkUHO42P1w4qBVzajal81_-N7oP-6w&s",
|
||||||
|
"isActive": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"id": "aaa",
|
||||||
|
"categoryId": "aa",
|
||||||
|
"price": 50000,
|
||||||
|
"iconUrl": "https://cdn2.cellphones.com.vn/insecure/rs:fill:358:358/q:90/plain/https://cellphones.com.vn/media/catalog/product/i/p/iphone-15-plus-256gb_2.png",
|
||||||
|
"isActive": true,
|
||||||
|
"name": "IP Pro",
|
||||||
|
"des": "asasasa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d317",
|
||||||
|
"name": "Sản Phẩm Vip",
|
||||||
|
"price": 100000,
|
||||||
|
"iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759976982/z7kus1kwxqfahi6bcwwo.png",
|
||||||
|
"categoryId": "aa",
|
||||||
|
"des": "<div class=\"product-heading\">\n<h2>Thông tin sản phẩm</h2>\n</div>\n<div class=\"product-wrap\">\n<div class=\"product-desc--content expandable-toggle opened expanded\">\n<div class=\"desc-content\">\n<p> </p>\n<h2><strong>Đánh giá chi tiết màn hình Viewsonic VA2432A-H 24\" IPS 120Hz viền mỏng</strong></h2>\n<p>Với tần số quét 120Hz và tấm nền IPS, màn hình Viewsonic VA2432A-H 24\" là một lựa chọn tuyệt vời cho cả game thủ và những người làm việc đồ họa. Chiếc màn hình này hứa hẹn sẽ đem lại cho bạn những hình ảnh sinh động và mượt mà, hỗ trợ nâng cao năng suất hoạt động.</p>\n<h3><strong>Hình ảnh sắc nét với tần số quét 120Hz, tốc độ phản hồi 1ms</strong></h3>\n<p>Tần số quét 120Hz của màn hình ViewSonic VA2432A-H giúp hình ảnh chuyển động mượt mà hơn gấp đôi so với <a href=\"https://gearvn.com/pages/man-hinh\">màn hình máy tính</a> 60Hz thông thường. Bạn sẽ không còn bỏ lỡ bất kỳ chi tiết nào trong những pha hành động nhanh của game FPS. Song song đó với thời gian phản hồi 1ms (MPRT) siêu nhanh giúp loại bỏ hiện tượng bóng mờ, mang đến trải nghiệm chơi game mượt mà và không bị giật lag.</p>\n<p><img src=\"https://product.hstatic.net/200000722513/product/view_va2432a-h_gearvn_9f5ded4d703e45fa9de460c8ce23bcc7_1024x1024.jpg\" alt=\"Màn hình Viewsonic VA2432A-H 24\"></p>\n<h3><strong>Ngoại hình hiện đại, tinh tế với ba cạnh không viền</strong></h3>\n<p><a href=\"https://gearvn.com/collections/man-hinh-viewsonic\">Màn hình Viewsonic</a> VA2432A-H có thiết kế hiện đại và tối giản với viền màn hình siêu mỏng, tạo cảm giác màn hình tràn viền, giúp bạn tập trung vào nội dung hiển thị mà không bị phân tán bởi các chi tiết thừa. Phần chân đế thường được làm từ chất liệu nhựa cao cấp mang lại cảm giác chắc chắn và bền bỉ.</p>\n</div>\n</div>\n</div>",
|
||||||
|
"isActive": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"carts": [],
|
||||||
|
"receipts": [
|
||||||
|
{
|
||||||
|
"id": "8b5f",
|
||||||
|
"userId": "1",
|
||||||
|
"products": "[{\"productId\":\"aaa\",\"quantity\":2}]",
|
||||||
|
"progress": "PENDING",
|
||||||
|
"isPaid": false,
|
||||||
|
"payType": "BANK",
|
||||||
|
"bankCode": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "96b4",
|
||||||
|
"userId": "1",
|
||||||
|
"products": "[{\"productId\":\"d317\",\"quantity\":4},{\"productId\":\"aaa\",\"quantity\":1}]",
|
||||||
|
"progress": "PENDING",
|
||||||
|
"isPaid": false,
|
||||||
|
"payType": "BANK",
|
||||||
|
"bankCode": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
19
index.html
Normal file
19
index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Rikkei Store</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"
|
||||||
|
integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw=="
|
||||||
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
6422
package-lock.json
generated
Normal file
6422
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "react_project",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"sv": "json-server --watch db.json",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.6.1",
|
||||||
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
|
"@reduxjs/toolkit": "^2.9.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
|
"@tinymce/tinymce-react": "^6.3.0",
|
||||||
|
"antd": "^5.27.4",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"cloudinary": "^2.7.0",
|
||||||
|
"jose": "^6.1.0",
|
||||||
|
"json-server": "^1.0.0-beta.3",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"react-router": "^7.9.3",
|
||||||
|
"sass": "^1.93.2",
|
||||||
|
"tailwindcss": "^4.1.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.36.0",
|
||||||
|
"@types/react": "^19.1.13",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
|
"babel-plugin-react-compiler": "^19.1.0-rc.3",
|
||||||
|
"eslint": "^9.36.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.44.0",
|
||||||
|
"vite": "^7.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
38
src/App.tsx
Normal file
38
src/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { createContext, useEffect } from 'react'
|
||||||
|
import RouterSetup from './RouterSetup'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
import type { StoreType } from './stores'
|
||||||
|
import Loading from './components/Loading'
|
||||||
|
import { Apis } from './apis'
|
||||||
|
import { userAction } from './stores/slices/user.slice'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const userStore = useSelector((store: StoreType) => store.user)
|
||||||
|
const dipatch = useDispatch()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if(userStore.data && !userStore.loading) {
|
||||||
|
try {
|
||||||
|
Apis.cart.getCartByUserId(userStore.data.id)
|
||||||
|
.then(res => {
|
||||||
|
dipatch(userAction.initCartData(res))
|
||||||
|
})
|
||||||
|
|
||||||
|
Apis.cart.getReceiptById(userStore.data.id)
|
||||||
|
.then(res => {
|
||||||
|
dipatch(userAction.initReceiptData(res))
|
||||||
|
})
|
||||||
|
}catch(err) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [userStore.data, userStore.loading, userStore.reloadCart])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
userStore.loading ? <Loading />
|
||||||
|
: <RouterSetup />
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/RouterSetup.tsx
Normal file
39
src/RouterSetup.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Route, Routes } from 'react-router'
|
||||||
|
import Home from './pages/home/Home'
|
||||||
|
import Admin from './pages/admin/Admin'
|
||||||
|
import ProtectedAdmin from './pages/admin/auth/ProtectedAdmin'
|
||||||
|
import Auth from './pages/home/auth/Auth'
|
||||||
|
import UserManagement from './pages/admin/User/UserManagement'
|
||||||
|
import CategoryManagement from './pages/admin/Category/CategoryManagement'
|
||||||
|
import ProductManagement from './pages/admin/Product/ProductManagement'
|
||||||
|
import CreateProductForm from './pages/admin/Product/components/CreateProductForm'
|
||||||
|
import Cart from './pages/home/cart/Cart'
|
||||||
|
import HomeContent from './pages/home/HomeContent'
|
||||||
|
import Collection from './pages/home/collections/Collection'
|
||||||
|
import ProductDetail from './pages/home/product/ProductDetail'
|
||||||
|
import Receipts from './pages/home/receipts/Receipts'
|
||||||
|
|
||||||
|
export default function RouterSetup() {
|
||||||
|
return <Routes>
|
||||||
|
<Route path='/' element={<Home />}>
|
||||||
|
<Route path='/' element={<HomeContent />}></Route>
|
||||||
|
<Route path='cart' element={<Cart />}></Route>
|
||||||
|
<Route path='receipts' element={<Receipts />}></Route>
|
||||||
|
<Route path='collections/:categoryId' element={<Collection />}></Route>
|
||||||
|
<Route path='product/:productId' element={<ProductDetail />}></Route>
|
||||||
|
</Route>
|
||||||
|
<Route path='/auth' element={<Auth />}></Route>
|
||||||
|
<Route path='admin' element={
|
||||||
|
<ProtectedAdmin>
|
||||||
|
<Admin />
|
||||||
|
</ProtectedAdmin>
|
||||||
|
}>
|
||||||
|
<Route path='user' element={<UserManagement />}></Route>
|
||||||
|
<Route path='category' element={<CategoryManagement />}></Route>
|
||||||
|
<Route path='product' element={<ProductManagement />}>
|
||||||
|
<Route path='add' element={<CreateProductForm />}></Route>
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
}
|
||||||
61
src/apis/core/cart.api.ts
Normal file
61
src/apis/core/cart.api.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import type { PayType, Progress, Receipt } from "../../types/receipt";
|
||||||
|
|
||||||
|
export interface AddToCartDTO {
|
||||||
|
userId: string;
|
||||||
|
productId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckoutDTO {
|
||||||
|
userId: string;
|
||||||
|
products: string;
|
||||||
|
progress: Progress;
|
||||||
|
isPaid: boolean;
|
||||||
|
payType: PayType;
|
||||||
|
bankCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const CartApi = {
|
||||||
|
getCartByUserId: async (userId: string) => {
|
||||||
|
let result = await axios.get(`${import.meta.env.VITE_SV_HOST}/carts?userId=${userId}`)
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
addToCart: async (data: AddToCartDTO) => {
|
||||||
|
let resultExisted = await axios.get(`${import.meta.env.VITE_SV_HOST}/carts?productId=${data.productId}`)
|
||||||
|
if (!resultExisted.data[0]) {
|
||||||
|
/* thêm mới */
|
||||||
|
let addRes = await axios.post(`${import.meta.env.VITE_SV_HOST}/carts`, {
|
||||||
|
...data,
|
||||||
|
quantity: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
return addRes.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cập nhật */
|
||||||
|
let resultUpdate = await axios.patch(`${import.meta.env.VITE_SV_HOST}/carts/${resultExisted.data[0].id}`, {
|
||||||
|
quantity: resultExisted.data[0].quantity + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
return resultUpdate.data
|
||||||
|
},
|
||||||
|
updateCart: async (cartId: string, quantity: number) => {
|
||||||
|
let result = await axios.patch(`${import.meta.env.VITE_SV_HOST}/carts/${cartId}`, {
|
||||||
|
quantity
|
||||||
|
})
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
deleteCart: async (cartId: string) => {
|
||||||
|
let result = await axios.delete(`${import.meta.env.VITE_SV_HOST}/carts/${cartId}`)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
checkout: async (receipt: CheckoutDTO) => {
|
||||||
|
let result = await axios.post(`${import.meta.env.VITE_SV_HOST}/receipts`, receipt)
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
getReceiptById: async (userId: string) => {
|
||||||
|
let result = await axios.get(`${import.meta.env.VITE_SV_HOST}/receipts?userId=${userId}`)
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
}
|
||||||
20
src/apis/core/category.api.ts
Normal file
20
src/apis/core/category.api.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import axios from "axios"
|
||||||
|
|
||||||
|
|
||||||
|
export const CategoryApi = {
|
||||||
|
getAll: async () => {
|
||||||
|
let resultRes = await axios.get(`${import.meta.env.VITE_SV_HOST}/categories`)
|
||||||
|
return resultRes.data
|
||||||
|
},
|
||||||
|
create: async (data: {
|
||||||
|
title: string;
|
||||||
|
iconUrl: string
|
||||||
|
}) => {
|
||||||
|
let result = await axios.post(`${import.meta.env.VITE_SV_HOST}/categories`, {
|
||||||
|
...data,
|
||||||
|
isActive: true
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/apis/core/cloudinary.api.ts
Normal file
12
src/apis/core/cloudinary.api.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import axios from "axios"
|
||||||
|
|
||||||
|
export const CloudianryApi = {
|
||||||
|
upload: async (file: File) => {
|
||||||
|
let formData = new FormData()
|
||||||
|
formData.append("file", file)
|
||||||
|
formData.append("upload_preset", import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET)
|
||||||
|
formData.append("cloud_name", import.meta.env.VITE_CLOUDINARY_CLOUD_NAME)
|
||||||
|
let result = await axios.post(`https://api.cloudinary.com/v1_1/${import.meta.env.VITE_CLOUDINARY_CLOUD_NAME}/image/upload`, formData)
|
||||||
|
return result.data.secure_url
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/apis/core/product.api.ts
Normal file
31
src/apis/core/product.api.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import axios from "axios"
|
||||||
|
|
||||||
|
export interface CreateProductDTO {
|
||||||
|
categorieId: string;
|
||||||
|
price: number;
|
||||||
|
iconUrl: string;
|
||||||
|
name: string;
|
||||||
|
des: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductApi = {
|
||||||
|
findAll: async () => {
|
||||||
|
let result = await axios.get(`${import.meta.env.VITE_SV_HOST}/products`)
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
create: async (data: CreateProductDTO) => {
|
||||||
|
let result = await axios.post(`${import.meta.env.VITE_SV_HOST}/products`, {
|
||||||
|
...data,
|
||||||
|
isActive: true
|
||||||
|
})
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
findListWithCategoryId: async (categoryId: string) => {
|
||||||
|
let result = await axios.get(`${import.meta.env.VITE_SV_HOST}/products?categoryId=${categoryId}`)
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
findProductById: async (productId: string) => {
|
||||||
|
let result = await axios.get(`${import.meta.env.VITE_SV_HOST}/products/` + productId)
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
}
|
||||||
159
src/apis/core/user.api.ts
Normal file
159
src/apis/core/user.api.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import axios from "axios"
|
||||||
|
import type { User } from "../../types/user.type"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { ApiUtil } from "../../utils/api.util"
|
||||||
|
import * as jose from 'jose'
|
||||||
|
|
||||||
|
export interface UserSignInDTO {
|
||||||
|
emailOrUserName: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserFindAllDTO {
|
||||||
|
_page?: number,
|
||||||
|
_per_page?: number,
|
||||||
|
userName?: string,
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserApi = {
|
||||||
|
signIn: async (data: UserSignInDTO) => {
|
||||||
|
let userData = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?email=${data.emailOrUserName}`)
|
||||||
|
if (userData.data.length == 0) {
|
||||||
|
userData = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?userName=${data.emailOrUserName}`)
|
||||||
|
}
|
||||||
|
if (userData.data.length == 0) {
|
||||||
|
throw ({
|
||||||
|
message: "Không tìm thấy người dùng tương ứng, bạn vui lòng kiểm tra lại!",
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (userData.data[0].password != data.password) {
|
||||||
|
throw ({
|
||||||
|
message: "Mật khẩu không chính xác!",
|
||||||
|
data: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return createToken(userData.data[0].id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
signUp: async (data: User) => {
|
||||||
|
let userNameExistedRes = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?userName=${data.userName}`)
|
||||||
|
if (userNameExistedRes.data.length > 0) {
|
||||||
|
throw ({
|
||||||
|
mes: "Tên đăng nhập đã tồn tại"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let emailExistedRes = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?email=${data.email}`)
|
||||||
|
if (emailExistedRes.data.length > 0) {
|
||||||
|
throw ({
|
||||||
|
mes: "Email đã tồn tại"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let newUserRes = await axios.post(`${import.meta.env.VITE_SV_HOST}/users`, data)
|
||||||
|
return newUserRes.data
|
||||||
|
},
|
||||||
|
findByUserName: async (userName: string) => {
|
||||||
|
let userData = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?userName=${userName}`)
|
||||||
|
|
||||||
|
if (userData.data.length > 0) {
|
||||||
|
return {
|
||||||
|
message: "Tìm thấy 1 người dùng",
|
||||||
|
data: userData.data[0]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw ({
|
||||||
|
message: "Không tìm thấy người dùng nào",
|
||||||
|
data: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
findByEmail: async (email: string) => {
|
||||||
|
let userData = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?email=${email}`)
|
||||||
|
|
||||||
|
if (userData.data.length > 0) {
|
||||||
|
return {
|
||||||
|
message: "Tìm thấy 1 người dùng",
|
||||||
|
data: userData.data[0]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw ({
|
||||||
|
message: "Không tìm thấy người dùng nào",
|
||||||
|
data: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
findById: async (id: string) => {
|
||||||
|
let userData = await axios.get(`${import.meta.env.VITE_SV_HOST}/users/${id}`)
|
||||||
|
|
||||||
|
if (userData.data) {
|
||||||
|
return {
|
||||||
|
message: "Tìm thấy 1 người dùng",
|
||||||
|
data: userData.data
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw ({
|
||||||
|
message: "Không tìm thấy người dùng nào",
|
||||||
|
data: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
findAll: async (query?: UserFindAllDTO) => {
|
||||||
|
let result = await axios.get(`${import.meta.env.VITE_SV_HOST}/users?` + ApiUtil.writeQuery(query))
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
me: async (token: string) => {
|
||||||
|
let tokenData = await decodeToken(token)
|
||||||
|
|
||||||
|
if (!tokenData) {
|
||||||
|
throw ({
|
||||||
|
message: "Token không chính xác!"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let { userId } = tokenData;
|
||||||
|
|
||||||
|
let getUserByIdRes = await axios.get(`${import.meta.env.VITE_SV_HOST}/users/${userId}`)
|
||||||
|
|
||||||
|
if (!getUserByIdRes.data) {
|
||||||
|
throw ({
|
||||||
|
message: "Lỗi lấy dữ liệu"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = await new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(getUserByIdRes.data)
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function createToken(userId: string) {
|
||||||
|
const secret = new TextEncoder().encode(import.meta.env.VITE_JWT_TOKEN);
|
||||||
|
|
||||||
|
const token = await new jose.SignJWT({ userId })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('2h')
|
||||||
|
.sign(secret);
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decodeToken(token: string) {
|
||||||
|
try {
|
||||||
|
const secret = new TextEncoder().encode(import.meta.env.VITE_JWT_TOKEN);
|
||||||
|
|
||||||
|
const { payload } = await jose.jwtVerify(token, secret, {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token không hợp lệ hoặc đã hết hạn:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/apis/index.ts
Normal file
13
src/apis/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { CartApi } from "./core/cart.api";
|
||||||
|
import { CategoryApi } from "./core/category.api";
|
||||||
|
import { CloudianryApi } from "./core/cloudinary.api";
|
||||||
|
import { ProductApi } from "./core/product.api";
|
||||||
|
import { UserApi } from "./core/user.api";
|
||||||
|
|
||||||
|
export const Apis = {
|
||||||
|
user: UserApi,
|
||||||
|
category: CategoryApi,
|
||||||
|
cloundInary: CloudianryApi,
|
||||||
|
product: ProductApi,
|
||||||
|
cart: CartApi
|
||||||
|
}
|
||||||
BIN
src/assets/img/logo.png
Normal file
BIN
src/assets/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
22
src/components/Loading.tsx
Normal file
22
src/components/Loading.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-white/70 backdrop-blur-sm">
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<div className="h-14 w-14 rounded-full bg-gray-200 animate-pulse" />
|
||||||
|
<svg
|
||||||
|
className="absolute -right-2 -bottom-2 h-7 w-7 animate-spin"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="text-gray-300 opacity-70" />
|
||||||
|
<path d="M22 12a10 10 0 00-10-10" stroke="currentColor" strokeWidth="3" className="text-indigo-500" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 font-medium">Đang tải dữ liệu xác thực...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/main.css
Normal file
1
src/main.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
14
src/main.tsx
Normal file
14
src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import { BrowserRouter } from 'react-router'
|
||||||
|
import './main.css'
|
||||||
|
import '@ant-design/v5-patch-for-react-19';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { store } from './stores/index.ts';
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<Provider store={store}>
|
||||||
|
<App />
|
||||||
|
</Provider>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
36
src/pages/admin/Admin.tsx
Normal file
36
src/pages/admin/Admin.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Layout, theme } from 'antd';
|
||||||
|
import Slider from './components/Slider';
|
||||||
|
import HeaderCom from './components/Header';
|
||||||
|
import { Outlet } from 'react-router';
|
||||||
|
|
||||||
|
const { Content } = Layout;
|
||||||
|
|
||||||
|
export const Admin: React.FC = () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const {
|
||||||
|
token: { colorBgContainer, borderRadiusLG },
|
||||||
|
} = theme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Slider collapsed={collapsed} />
|
||||||
|
<Layout>
|
||||||
|
<HeaderCom collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||||
|
<Content
|
||||||
|
style={{
|
||||||
|
margin: '24px 16px',
|
||||||
|
padding: 24,
|
||||||
|
minHeight: 280,
|
||||||
|
background: colorBgContainer,
|
||||||
|
borderRadius: borderRadiusLG,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet/>
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Admin
|
||||||
163
src/pages/admin/Category/CategoryManagement.tsx
Normal file
163
src/pages/admin/Category/CategoryManagement.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import React, { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { Apis } from '../../../apis'
|
||||||
|
import type { Category } from '../../../types/category.type'
|
||||||
|
import { Button, Form, Input, Modal, Space, Table, Upload } from 'antd'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export default function CategoryManagement() {
|
||||||
|
/* State Modal */
|
||||||
|
const [createNewModalState, setCreateNewModalState] = useState(false)
|
||||||
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
|
||||||
|
const [loadingState, setLoadingState] = useState(false)
|
||||||
|
|
||||||
|
async function getCategories() {
|
||||||
|
try {
|
||||||
|
let result = await Apis.category.getAll()
|
||||||
|
setCategories(result)
|
||||||
|
} catch (err) {
|
||||||
|
/* err */
|
||||||
|
alert("tạch!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCategories()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
|
||||||
|
{
|
||||||
|
title: 'Số Thứ Tự',
|
||||||
|
key: 'index',
|
||||||
|
render: (_: any, __: any, index: number) => index + 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tiêu Đề',
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Icon',
|
||||||
|
dataIndex: 'iconUrl',
|
||||||
|
key: 'iconUrl',
|
||||||
|
render: (_: any, record: Category) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<img src={record.iconUrl} style={{
|
||||||
|
width: "50px",
|
||||||
|
height: "50px",
|
||||||
|
borderRadius: "50"
|
||||||
|
}} />
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Trạng Thái',
|
||||||
|
dataIndex: 'isActive',
|
||||||
|
key: 'isActive',
|
||||||
|
render: (_: any, record: Category) => (
|
||||||
|
<Space size="middle">
|
||||||
|
{
|
||||||
|
record.isActive ?
|
||||||
|
<Button color="danger" variant="solid">Ngừng Hoạt Động</Button>
|
||||||
|
: <Button type='primary'>Kích Hoạt</Button>
|
||||||
|
}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Công Cụ',
|
||||||
|
dataIndex: 'isActive',
|
||||||
|
key: 'isActive',
|
||||||
|
render: (_: any, record: Category) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Button color="danger" variant="solid">Xóa</Button>
|
||||||
|
<Button type='primary'>Sửa</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/* form */
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: "relative"
|
||||||
|
}}>
|
||||||
|
<h1>Quản Lý Danh Mục</h1>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setCreateNewModalState(true)
|
||||||
|
}} type='primary'>Thêm Mới</Button>
|
||||||
|
<Table
|
||||||
|
dataSource={categories}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
{/* Modal Create new */}
|
||||||
|
<Modal
|
||||||
|
title="Thêm mới danh mục"
|
||||||
|
closable={{ 'aria-label': 'Custom Close Button' }}
|
||||||
|
open={createNewModalState}
|
||||||
|
onOk={() => {
|
||||||
|
if(loadingState) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// setCreateNewModalState(false)
|
||||||
|
try {
|
||||||
|
form
|
||||||
|
.validateFields()
|
||||||
|
.then(async (values) => {
|
||||||
|
setLoadingState(true)
|
||||||
|
let result = await Apis.cloundInary.upload(values.file.file)
|
||||||
|
let newCategory = {
|
||||||
|
title: values.title,
|
||||||
|
iconUrl: result
|
||||||
|
}
|
||||||
|
|
||||||
|
let resultRes = await Apis.category.create(newCategory)
|
||||||
|
setCategories([
|
||||||
|
...categories,
|
||||||
|
resultRes
|
||||||
|
])
|
||||||
|
setLoadingState(false)
|
||||||
|
setCreateNewModalState(false)
|
||||||
|
})
|
||||||
|
} catch (er) {
|
||||||
|
setLoadingState(false)
|
||||||
|
setCreateNewModalState(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setCreateNewModalState(false)
|
||||||
|
}}
|
||||||
|
okButtonProps={{
|
||||||
|
htmlType: "submit"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="title"
|
||||||
|
label="Tên danh mục"
|
||||||
|
rules={[{ required: true, message: 'Nhập tên danh mục!' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Nhập title" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="file" label="Ảnh">
|
||||||
|
<Upload beforeUpload={() => false}>
|
||||||
|
<Input type="file" />
|
||||||
|
</Upload>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{
|
||||||
|
loadingState && <div style={{
|
||||||
|
zIndex:"99999999"
|
||||||
|
}} className="absolute inset-0 flex items-center justify-center bg-white/60 backdrop-blur-sm z-50">
|
||||||
|
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
src/pages/admin/Product/ProductManagement.tsx
Normal file
136
src/pages/admin/Product/ProductManagement.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import type { Product } from '../../../types/product.type'
|
||||||
|
import { Apis } from '../../../apis'
|
||||||
|
import { Button, Space, Table } from 'antd'
|
||||||
|
import { Outlet, useNavigate } from 'react-router'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import type { StoreType } from '../../../stores'
|
||||||
|
import type { Category } from '../../../types/category.type'
|
||||||
|
|
||||||
|
export default function ProductManagement() {
|
||||||
|
const [categoryList, setCategoryList] = useState<Category[]>([])
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [productList, setProductList] = useState<Product[]>([])
|
||||||
|
const [selectProduct, setSelectProduct] = useState<Product>(null)
|
||||||
|
|
||||||
|
|
||||||
|
async function fetchProductList() {
|
||||||
|
try {
|
||||||
|
await Apis.category.getAll()
|
||||||
|
.then(res => {
|
||||||
|
setCategoryList(res)
|
||||||
|
})
|
||||||
|
|
||||||
|
let result = await Apis.product.findAll()
|
||||||
|
setProductList(result)
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProductList()
|
||||||
|
}, [])
|
||||||
|
const columns = [
|
||||||
|
|
||||||
|
{
|
||||||
|
title: 'Số Thứ Tự',
|
||||||
|
key: 'index',
|
||||||
|
render: (_: any, __: any, index: number) => index + 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tên sản phẩm',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Giá sản phẩm',
|
||||||
|
dataIndex: 'price',
|
||||||
|
key: 'price',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tên danh mục',
|
||||||
|
dataIndex: 'categoryId',
|
||||||
|
key: 'categoryId',
|
||||||
|
render: (_: any, record: Product) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<span>{categoryList.find((item) => {
|
||||||
|
return item.id == record.categoryId
|
||||||
|
})?.title || "Unknow"}</span>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Icon',
|
||||||
|
dataIndex: 'iconUrl',
|
||||||
|
key: 'iconUrl',
|
||||||
|
render: (_: any, record: Product) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<img src={record.iconUrl} style={{
|
||||||
|
width: "50px",
|
||||||
|
height: "50px",
|
||||||
|
borderRadius: "50"
|
||||||
|
}} />
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Trạng Thái',
|
||||||
|
dataIndex: 'isActive',
|
||||||
|
key: 'isActive',
|
||||||
|
render: (_: any, record: Product) => (
|
||||||
|
<Space size="middle">
|
||||||
|
{
|
||||||
|
record.isActive ?
|
||||||
|
<Button color="danger" variant="solid">Ngừng Hoạt Động</Button>
|
||||||
|
: <Button type='primary'>Kích Hoạt</Button>
|
||||||
|
}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Mô Tả',
|
||||||
|
dataIndex: 'isActive',
|
||||||
|
key: 'isActive',
|
||||||
|
render: (_: any, record: Product) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Button onClick={() => {
|
||||||
|
setSelectProduct(record)
|
||||||
|
}}>Xem Mô Tả</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Công Cụ',
|
||||||
|
dataIndex: 'isActive',
|
||||||
|
key: 'isActive',
|
||||||
|
render: (_: any, record: Product) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Button color="danger" variant="solid">Xóa</Button>
|
||||||
|
<Button type='primary'>Sửa</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
selectProduct && <div className='des_ox' dangerouslySetInnerHTML={{ __html: selectProduct.des }}></div>
|
||||||
|
}
|
||||||
|
<h1>ProductManagement</h1>
|
||||||
|
<Button onClick={() => {
|
||||||
|
navigate("add")
|
||||||
|
}} type='primary'>Add New Product</Button>
|
||||||
|
<Outlet></Outlet>
|
||||||
|
<Table
|
||||||
|
dataSource={productList}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
src/pages/admin/Product/components/CreateProductForm.tsx
Normal file
148
src/pages/admin/Product/components/CreateProductForm.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { Editor } from '@tinymce/tinymce-react';
|
||||||
|
import type { Category } from '../../../../types/category.type';
|
||||||
|
import { Apis } from '../../../../apis';
|
||||||
|
import { Button, Form, Input, InputNumber, Select, Upload } from 'antd';
|
||||||
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { loadingAction } from '../../../../stores/slices/loading.slice';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default function CreateProductForm() {
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const editorRef = useRef(null);
|
||||||
|
const [categoryList, setCategoryList] = useState<Category[]>([])
|
||||||
|
|
||||||
|
const [apiLoading, setApiLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Apis.category.getAll()
|
||||||
|
.then(res => {
|
||||||
|
setCategoryList(res)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const handleFinish = async (values: any) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const file = values.iconUrl.file;
|
||||||
|
if (!file) {
|
||||||
|
alert("phải chọn hình")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let iconUrl = await Apis.cloundInary.upload(file)
|
||||||
|
const data = {
|
||||||
|
...values,
|
||||||
|
iconUrl: iconUrl,
|
||||||
|
des: editorRef.current?.getContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await Apis.product.create(data);
|
||||||
|
window.location.href = "/admin/product"
|
||||||
|
|
||||||
|
} catch (er) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
border: "1px solid black"
|
||||||
|
}}>
|
||||||
|
<h1>Thêm sản phẩm</h1>
|
||||||
|
{
|
||||||
|
apiLoading ? <>Đang thêm ....</> : <>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleFinish}
|
||||||
|
style={{ maxWidth: 500, margin: 'auto' }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="Tên sản phẩm"
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập tên sản phẩm!' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Nhập tên sản phẩm" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Giá sản phẩm"
|
||||||
|
name="price"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập giá sản phẩm!' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="Nhập giá sản phẩm"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Hình ảnh sản phẩm"
|
||||||
|
name="iconUrl"
|
||||||
|
valuePropName="file"
|
||||||
|
getValueFromEvent={(e) => e}
|
||||||
|
rules={[{ required: true, message: 'Vui lòng chọn hình ảnh!' }]}
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
beforeUpload={() => false} // chặn upload tự động, chỉ lấy file
|
||||||
|
maxCount={1}
|
||||||
|
listType="picture"
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />}>Chọn ảnh</Button>
|
||||||
|
</Upload>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Danh mục"
|
||||||
|
name="categoryId"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng chọn danh mục!' }]}
|
||||||
|
>
|
||||||
|
<Select placeholder="Chọn danh mục">
|
||||||
|
{categoryList.map((item) => (
|
||||||
|
<Select.Option key={item.id} value={item.id}>
|
||||||
|
{item.title}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block>
|
||||||
|
Thêm sản phẩm
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<p>Mô Tả Sản Phẩm</p>
|
||||||
|
<Editor
|
||||||
|
apiKey='gl284vy5pkwj6d6jdt9r7n6z7398b9gmowka4onfdqr1wq6h'
|
||||||
|
onInit={(_evt, editor) => editorRef.current = editor}
|
||||||
|
initialValue="<p>This is the initial content of the editor.</p>"
|
||||||
|
init={{
|
||||||
|
height: 500,
|
||||||
|
menubar: false,
|
||||||
|
plugins: [
|
||||||
|
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
|
||||||
|
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
|
||||||
|
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
|
||||||
|
],
|
||||||
|
toolbar: 'undo redo | blocks | ' +
|
||||||
|
'bold italic forecolor | alignleft aligncenter ' +
|
||||||
|
'alignright alignjustify | bullist numlist outdent indent | ' +
|
||||||
|
'removeformat | help',
|
||||||
|
content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/pages/admin/User/UserManagement.tsx
Normal file
142
src/pages/admin/User/UserManagement.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Apis } from '../../../apis'
|
||||||
|
import { UserRole, type User } from '../../../types/user.type'
|
||||||
|
import { Pagination, Table } from 'antd'
|
||||||
|
import Search, { type SearchProps } from 'antd/es/input/Search'
|
||||||
|
import type { UserFindAllDTO } from '../../../apis/core/user.api'
|
||||||
|
|
||||||
|
|
||||||
|
interface PaginnationType {
|
||||||
|
fist: number,
|
||||||
|
items: number,
|
||||||
|
last: number,
|
||||||
|
next: number,
|
||||||
|
pages: number,
|
||||||
|
prev: number
|
||||||
|
}
|
||||||
|
interface UserFetchResDTO extends PaginnationType {
|
||||||
|
data: User[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManagement() {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
const maxItem = 1;
|
||||||
|
const [curPage, setCurPage] = useState(+params.get('curPage') || 1)
|
||||||
|
const [userList, setUserList] = useState<User[]>([])
|
||||||
|
const [pagination, setPagination] = useState<PaginnationType | null>(null)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [role, setRole] = useState("")
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
queryParams.set('curPage', String(1))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserList()
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (curPage) {
|
||||||
|
queryParams.set('curPage', String(curPage))
|
||||||
|
}
|
||||||
|
}, [curPage, search, role])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserList()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* Antd Table */
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'Mã Người Dùng',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tên đăng nhập',
|
||||||
|
dataIndex: 'userName',
|
||||||
|
key: 'userName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vai Trò',
|
||||||
|
dataIndex: 'role',
|
||||||
|
key: 'role',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tên Hiển Thị',
|
||||||
|
dataIndex: 'displayName',
|
||||||
|
key: 'displayName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Email',
|
||||||
|
dataIndex: 'email',
|
||||||
|
key: 'email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Số điện thoại',
|
||||||
|
dataIndex: 'phoneNumber',
|
||||||
|
key: 'phoneNumber',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Trạng thái',
|
||||||
|
key: 'status',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
/* search */
|
||||||
|
const onSearch: SearchProps['onSearch'] = (value, _e, info) => {
|
||||||
|
setSearch(value)
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchUserList() {
|
||||||
|
try {
|
||||||
|
let query: UserFindAllDTO = {
|
||||||
|
_page: curPage,
|
||||||
|
_per_page: maxItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query.userName = search
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
query.role = role
|
||||||
|
}
|
||||||
|
|
||||||
|
let userRes = await Apis.user.findAll(query) as UserFetchResDTO
|
||||||
|
setUserList(userRes.data)
|
||||||
|
setPagination(userRes)
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Quản Lý Người Dùng</h1>
|
||||||
|
<Search placeholder="input search text" onSearch={onSearch} enterButton />
|
||||||
|
<select onChange={(e) => {
|
||||||
|
setRole(e.target.value)
|
||||||
|
}}>
|
||||||
|
<option value={""}>Chọn role</option>
|
||||||
|
{
|
||||||
|
Object.entries(UserRole).map(item => {
|
||||||
|
return (
|
||||||
|
<option value={item[0]}>{item[1]}</option>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<Table
|
||||||
|
dataSource={userList}
|
||||||
|
columns={columns}
|
||||||
|
pagination={{
|
||||||
|
pageSize: maxItem,
|
||||||
|
total: pagination?.pages || 0,
|
||||||
|
onChange: (curPage, maxItem) => {
|
||||||
|
setCurPage(curPage)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
src/pages/admin/auth/ProtectedAdmin.tsx
Normal file
126
src/pages/admin/auth/ProtectedAdmin.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { message, Modal } from 'antd';
|
||||||
|
import React, { type FormEvent, type ReactNode } from 'react'
|
||||||
|
import { UserApi, type UserSignInDTO } from '../../../apis/core/user.api';
|
||||||
|
import { Apis } from '../../../apis';
|
||||||
|
import { UserRole, type User } from '../../../types/user.type';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import type { StoreType } from '../../../stores';
|
||||||
|
|
||||||
|
export default function ProtectedAdmin(
|
||||||
|
{ children }: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const userStore = useSelector((store: StoreType) => {
|
||||||
|
return store.user
|
||||||
|
})
|
||||||
|
|
||||||
|
/* Đã đăng nhập và là master hoặc admin */
|
||||||
|
if (userStore.data?.role == UserRole.MASTER || userStore.data?.role == UserRole.ADMIN) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* chưa đăng nhập */
|
||||||
|
if (!userStore.data && !userStore.loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-4">
|
||||||
|
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 text-center border-b border-gray-200">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800">Rikkei Phone Store</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-1">Chào mừng bạn quay trở lại 👋</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="p-6">
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
signInHandle(e)
|
||||||
|
}} className="space-y-5">
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Email của bạn
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name='emailOrUserName'
|
||||||
|
type="text"
|
||||||
|
className="mt-1 w-full rounded-xl border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Mật khẩu của bạn
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
className="mt-1 w-full rounded-xl border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remember me + Forgot */}
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<a href="#" className="text-blue-600 hover:underline">
|
||||||
|
Quên mật khẩu?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="cursor-pointer w-full py-2 rounded-xl bg-blue-600 text-white font-semibold shadow-md hover:bg-blue-700 transition"
|
||||||
|
>
|
||||||
|
Đăng nhập
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 bg-gray-50 text-center text-sm text-gray-500">
|
||||||
|
© 2025 Rikkei Admin
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* đã đăng nhập nhưng không phải master hoặc admin */
|
||||||
|
if (userStore.data?.role) {
|
||||||
|
window.location.href = "/"
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* chưa vào case, default */
|
||||||
|
|
||||||
|
|
||||||
|
async function signInHandle(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
let data: UserSignInDTO = {
|
||||||
|
emailOrUserName: (e.target as any).emailOrUserName.value,
|
||||||
|
password: (e.target as any).password.value,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let result = await Apis.user.signIn(data)
|
||||||
|
localStorage.setItem("token", result)
|
||||||
|
Modal.confirm({
|
||||||
|
title: "Đăng nhập thành công",
|
||||||
|
onOk: () => {
|
||||||
|
window.location.reload()
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/pages/admin/components/Header.tsx
Normal file
36
src/pages/admin/components/Header.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Layout, theme } from 'antd';
|
||||||
|
const { Header } = Layout;
|
||||||
|
|
||||||
|
export default function HeaderCom({ collapsed, setCollapsed }: { collapsed: boolean, setCollapsed: any }) {
|
||||||
|
const {
|
||||||
|
token: { colorBgContainer },
|
||||||
|
} = theme.useToken();
|
||||||
|
return (
|
||||||
|
<Header style={{ padding: 0, background: colorBgContainer }}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select onChange={(e) => {
|
||||||
|
localStorage.setItem("lng", e.target.value)
|
||||||
|
}}>
|
||||||
|
<option value="vi">Tiếng Việt</option>
|
||||||
|
<option value="en">Tiếng Anh</option>
|
||||||
|
<option value="ja">Tiếng Nhật</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button onClick={() => {
|
||||||
|
localStorage.removeItem("token")
|
||||||
|
window.location.reload()
|
||||||
|
}}>logout</Button>
|
||||||
|
</Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/pages/admin/components/Slider.tsx
Normal file
45
src/pages/admin/components/Slider.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { UploadOutlined, UserOutlined, VideoCameraOutlined } from '@ant-design/icons'
|
||||||
|
import { Menu } from 'antd'
|
||||||
|
import Sider from 'antd/es/layout/Sider'
|
||||||
|
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
|
export default function Slider({ collapsed }: { collapsed: boolean }) {
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
let menuItem: ItemType<MenuItemType>[] = [
|
||||||
|
{
|
||||||
|
key: 'user',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: "Quản lý người dùng",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'category',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: "Quản lý danh mục",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'product',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: "Quản lý sản phẩm",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sider style={{
|
||||||
|
height: "100vh"
|
||||||
|
}} trigger={null} collapsible collapsed={collapsed}>
|
||||||
|
<div className="demo-logo-vertical" />
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
defaultSelectedKeys={['1']}
|
||||||
|
items={menuItem}
|
||||||
|
onClick={(e) => {
|
||||||
|
navigate(e.key)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/pages/home/Home.tsx
Normal file
24
src/pages/home/Home.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React, { useContext } from 'react'
|
||||||
|
import { Link, Outlet } from 'react-router'
|
||||||
|
import type { User } from '../../types/user.type'
|
||||||
|
import './home.scss'
|
||||||
|
import Header from './components/Header/Header'
|
||||||
|
import Footer from './components/Footer/Footer'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className='home_page'>
|
||||||
|
{/* Header */}
|
||||||
|
<Header/>
|
||||||
|
{/* Body */}
|
||||||
|
<div id='container'>
|
||||||
|
<div className='content'>
|
||||||
|
<Outlet/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/pages/home/HomeContent.tsx
Normal file
31
src/pages/home/HomeContent.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import type { Category } from '../../types/category.type'
|
||||||
|
import { Apis } from '../../apis'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
|
||||||
|
export default function HomeContent() {
|
||||||
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Apis.category.getAll()
|
||||||
|
.then(res => {
|
||||||
|
setCategories(res)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
HomeContent
|
||||||
|
<ul>
|
||||||
|
{
|
||||||
|
categories.map(item => {
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<Link to={`/collections/${item.id}`}>{item.title}</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
161
src/pages/home/auth/Auth.tsx
Normal file
161
src/pages/home/auth/Auth.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React, { useEffect, useRef, type FormEvent } from 'react'
|
||||||
|
import './auth.scss'
|
||||||
|
import { UserRole, UserStatus, type User } from '../../../types/user.type'
|
||||||
|
import { Apis } from '../../../apis'
|
||||||
|
import { message, Modal } from 'antd'
|
||||||
|
import { FormUtil } from '../../../utils/form.util'
|
||||||
|
import type { UserSignInDTO } from '../../../apis/core/user.api'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import type { StoreType } from '../../../stores'
|
||||||
|
export default function Auth() {
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
|
||||||
|
|
||||||
|
async function handleSignup(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
try {
|
||||||
|
let newUser = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
userName: (e.target as any).userName.value,
|
||||||
|
password: (e.target as any).password.value,
|
||||||
|
role: UserRole.USER,
|
||||||
|
displayName: (e.target as any).displayName.value,
|
||||||
|
email: (e.target as any).email.value,
|
||||||
|
phoneNumber: (e.target as any).phoneNumber.value,
|
||||||
|
status: UserStatus.ACTIVE,
|
||||||
|
banReason: ""
|
||||||
|
}
|
||||||
|
let newUserResult = (await Apis.user.signUp(newUser)) as User
|
||||||
|
Modal.confirm({
|
||||||
|
title: "Thông báo",
|
||||||
|
content: `Bạn đã đăng ký thành công với tên đăng nhập là: ${newUserResult.userName}, id của bạn là: ${newUserResult.id}`,
|
||||||
|
onOk: () => {
|
||||||
|
FormUtil.resetForm(e)
|
||||||
|
containerRef.current.classList.remove("right-panel-active");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err.mes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignin(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
let loginData: UserSignInDTO = {
|
||||||
|
emailOrUserName: (e.target as any).emailOrUserName.value,
|
||||||
|
password: (e.target as any).password.value
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let data = await Apis.user.signIn(loginData)
|
||||||
|
localStorage.setItem("token", data)
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Chào mừng bạn đã quay trở lại`,
|
||||||
|
content: ``,
|
||||||
|
onOk: () => {
|
||||||
|
window.location.href = "/"
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
window.location.href = "/"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userStore = useSelector((store: StoreType) => {
|
||||||
|
return store.user
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userStore.loading && userStore.data) {
|
||||||
|
window.location.href = "/"
|
||||||
|
}
|
||||||
|
}, [userStore.data, userStore.loading])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
userStore.data ? <>Đã đăng nhập</> : (
|
||||||
|
<div className='auth_page'>
|
||||||
|
<div ref={containerRef} className="container" id="container">
|
||||||
|
{/* Form Đăng Ký */}
|
||||||
|
<div className="form-container sign-up-container">
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
handleSignup(e)
|
||||||
|
}}>
|
||||||
|
<h1>Tạo tài khoản</h1>
|
||||||
|
<div className="social-container">
|
||||||
|
<a href="#" className="social">
|
||||||
|
<i className="fab fa-facebook-f" />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="social">
|
||||||
|
<i className="fab fa-google-plus-g" />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="social">
|
||||||
|
<i className="fab fa-linkedin-in" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<input type="text" placeholder="userName" name='userName' />
|
||||||
|
<input type="password" placeholder="Password" name='password' />
|
||||||
|
<input type="text" placeholder="displayName" name='displayName' />
|
||||||
|
<input type="email" placeholder="email" name='email' />
|
||||||
|
<input type="text" placeholder="phoneNumber" name='phoneNumber' />
|
||||||
|
<button className='cursor-pointer'>Đăng Ký</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/* Form Đăng Nhập */}
|
||||||
|
<div className="form-container sign-in-container">
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
handleSignin(e)
|
||||||
|
}}>
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<div className="social-container">
|
||||||
|
<a href="#" className="social">
|
||||||
|
<i className="fab fa-facebook-f" />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="social">
|
||||||
|
<i className="fab fa-google-plus-g" />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="social">
|
||||||
|
<i className="fab fa-linkedin-in" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<span>or use your account</span>
|
||||||
|
<input type="text" placeholder="Email or Username" name='emailOrUserName' />
|
||||||
|
<input type="password" placeholder="Password" name='password' />
|
||||||
|
<a href="#">Forgot your password?</a>
|
||||||
|
<button>Sign In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/* Control */}
|
||||||
|
<div className="overlay-container">
|
||||||
|
<div className="overlay">
|
||||||
|
<div className="overlay-panel overlay-left">
|
||||||
|
<h1>Welcome Back!</h1>
|
||||||
|
<p>To keep connected with us please login with your personal info</p>
|
||||||
|
<button onClick={() => {
|
||||||
|
containerRef.current.classList.remove("right-panel-active");
|
||||||
|
}} className="ghost" id="signIn">
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overlay-panel overlay-right">
|
||||||
|
<h1>Hello, Friend!</h1>
|
||||||
|
<p>Enter your personal details and start journey with us</p>
|
||||||
|
<button onClick={() => {
|
||||||
|
containerRef.current.classList.add("right-panel-active")
|
||||||
|
}} className="ghost" id="signUp">
|
||||||
|
Sign Up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
254
src/pages/home/auth/auth.scss
Normal file
254
src/pages/home/auth/auth.scss
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css?family=Montserrat:400,800');
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth_page {
|
||||||
|
background: #f6f5f7;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
height: 100vh;
|
||||||
|
margin: -20px 0 50px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 100;
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin: 20px 0 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid #FF4B2B;
|
||||||
|
background-color: #FF4B2B;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 12px 45px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: transform 80ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 50px;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
background-color: #eee;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 15px;
|
||||||
|
margin: 8px 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25),
|
||||||
|
0 10px 10px rgba(0, 0, 0, 0.22);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 768px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
transition: all 0.6s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-in-container {
|
||||||
|
left: 0;
|
||||||
|
width: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container.right-panel-active .sign-in-container {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-up-container {
|
||||||
|
left: 0;
|
||||||
|
width: 50%;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container.right-panel-active .sign-up-container {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 1;
|
||||||
|
z-index: 5;
|
||||||
|
animation: show 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes show {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.6s ease-in-out;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container.right-panel-active .overlay-container {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
background: #FF416C;
|
||||||
|
background: -webkit-linear-gradient(to right, #FF4B2B, #FF416C);
|
||||||
|
background: linear-gradient(to right, #FF4B2B, #FF416C);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: 0 0;
|
||||||
|
color: #FFFFFF;
|
||||||
|
position: relative;
|
||||||
|
left: -100%;
|
||||||
|
height: 100%;
|
||||||
|
width: 200%;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 0.6s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container.right-panel-active .overlay {
|
||||||
|
transform: translateX(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-panel {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 40px;
|
||||||
|
text-align: center;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 50%;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 0.6s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-left {
|
||||||
|
transform: translateX(-20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container.right-panel-active .overlay-left {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-right {
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container.right-panel-active .overlay-right {
|
||||||
|
transform: translateX(20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-container {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-container a {
|
||||||
|
border: 1px solid #DDDDDD;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 5px;
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
background-color: #222;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
bottom: 0;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer p {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer i {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
color: #3c97bf;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/pages/home/cart/Cart.tsx
Normal file
171
src/pages/home/cart/Cart.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
import type { StoreType } from '../../../stores'
|
||||||
|
import type { CartItem } from '../../../types/cart.type'
|
||||||
|
import { Apis } from '../../../apis'
|
||||||
|
import type { Product } from '../../../types/product.type'
|
||||||
|
import { userAction } from '../../../stores/slices/user.slice'
|
||||||
|
import { Button } from 'antd'
|
||||||
|
import { PayType, Progress } from '../../../types/receipt'
|
||||||
|
|
||||||
|
export default function Cart() {
|
||||||
|
const userStore = useSelector((store: StoreType) => store.user)
|
||||||
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const [selectItemList, setSelectItemList] = useState<String[]>([]) // ["aa", "das", "asa"]
|
||||||
|
|
||||||
|
// Lấy thông tin product cho từng item trong giỏ
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchProducts() {
|
||||||
|
let temsArr: Product[] = []
|
||||||
|
for (const item of userStore.cart || []) {
|
||||||
|
const res = await Apis.product.findProductById(item.productId)
|
||||||
|
temsArr.push(res)
|
||||||
|
}
|
||||||
|
setProducts(temsArr)
|
||||||
|
}
|
||||||
|
fetchProducts()
|
||||||
|
}, [userStore.cart])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Tính tổng tiền
|
||||||
|
const total = userStore.cart?.reduce((cur, next) => {
|
||||||
|
return cur + (selectItemList.includes(next.id) ? next.quantity * products.find(item => item.id == next.productId)?.price || 0 : 0)
|
||||||
|
}, 0) || 0
|
||||||
|
|
||||||
|
async function checkout() {
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
let newData = {
|
||||||
|
userId: userStore.data?.id,
|
||||||
|
products: JSON.stringify(selectItemList.map((item) => {
|
||||||
|
// 04fd, 04fd
|
||||||
|
let cartItem = userStore.cart.find(itemF => itemF.id == item)
|
||||||
|
return {
|
||||||
|
productId: cartItem.productId,
|
||||||
|
quantity: cartItem.quantity
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
progress: Progress.PENDING,
|
||||||
|
isPaid: false,
|
||||||
|
payType: PayType.BANK,
|
||||||
|
bankCode: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await Apis.cart.checkout(newData)
|
||||||
|
|
||||||
|
for(let item of selectItemList) {
|
||||||
|
Apis.cart.deleteCart(item as string)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-6 text-center">🛒 Giỏ hàng của bạn</h1>
|
||||||
|
|
||||||
|
{userStore.cart?.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-500">Giỏ hàng trống.</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white shadow-md rounded-2xl p-4">
|
||||||
|
<ul className="divide-y divide-gray-200">
|
||||||
|
{userStore.cart.map((item: CartItem) => {
|
||||||
|
const product = products.find(pro => pro.id == item.productId)
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center gap-4 py-4"
|
||||||
|
>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<input checked={selectItemList.includes(item.id)} onChange={(e) => {
|
||||||
|
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectItemList([
|
||||||
|
...selectItemList,
|
||||||
|
item.id
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
setSelectItemList(selectItemList.filter(itemF => itemF != item.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
}} type="checkbox" />
|
||||||
|
{/* Ảnh sản phẩm */}
|
||||||
|
<div className="w-16 h-16 flex-shrink-0 overflow-hidden rounded-lg border">
|
||||||
|
{product ? (
|
||||||
|
<img
|
||||||
|
src={product.iconUrl}
|
||||||
|
alt={product.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gray-200 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thông tin */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="font-semibold text-gray-800">
|
||||||
|
{product ? product.name : 'Đang tải...'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{product ? product.price.toLocaleString() + ' đ' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Số lượng & tổng */}
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-700">SL: <input onChange={async (e) => {
|
||||||
|
try {
|
||||||
|
let result = await Apis.cart.updateCart(item.id, +e.target.value)
|
||||||
|
dispatch(userAction.changeLoad())
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}} type="number" min={1} defaultValue={item.quantity} /></p>
|
||||||
|
{product && (
|
||||||
|
<p className="font-semibold text-gray-800">
|
||||||
|
{(product.price * item.quantity).toLocaleString()} đ
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button onClick={async () => {
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result = await Apis.cart.deleteCart(item.id)
|
||||||
|
dispatch(userAction.changeLoad())
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}} type='primary'>Delete</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Tổng tiền */}
|
||||||
|
<div className="mt-6 border-t pt-4 text-right">
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
Tổng cộng:{' '}
|
||||||
|
<span className="text-blue-600">{total.toLocaleString()} đ</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nút hành động */}
|
||||||
|
<div className="mt-6 text-right">
|
||||||
|
<button onClick={() => {
|
||||||
|
checkout()
|
||||||
|
}} className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl transition">
|
||||||
|
Thanh toán
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
src/pages/home/collections/Collection.tsx
Normal file
48
src/pages/home/collections/Collection.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'react-router'
|
||||||
|
import { Apis } from '../../../apis'
|
||||||
|
import type { Product } from '../../../types/product.type'
|
||||||
|
|
||||||
|
export default function Collection() {
|
||||||
|
const { categoryId } = useParams()
|
||||||
|
const [productList, setProductList] = useState<Product[]>([])
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Apis.product.findListWithCategoryId((categoryId))
|
||||||
|
.then(res => {
|
||||||
|
setProductList(res)
|
||||||
|
})
|
||||||
|
}, [categoryId])
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">
|
||||||
|
Danh sách sản phẩm (Danh mục: {categoryId})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{productList.map((item) => (
|
||||||
|
<div
|
||||||
|
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = "/product/" + item.id
|
||||||
|
}}
|
||||||
|
key={item.id}
|
||||||
|
className="border rounded-xl p-3 shadow hover:shadow-lg transition bg-white"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={item.iconUrl}
|
||||||
|
alt={item.name}
|
||||||
|
className="w-full h-40 object-cover rounded-md mb-3"
|
||||||
|
/>
|
||||||
|
<h3 className="text-lg font-semibold">{item.name}</h3>
|
||||||
|
<p className="text-gray-600">Giá: {item.price.toLocaleString()}₫</p>
|
||||||
|
<p className={`text-sm mt-1 ${item.isActive ? 'text-green-600' : 'text-red-500'}`}>
|
||||||
|
{item.isActive ? 'Đang bán' : 'Ngừng bán'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/pages/home/components/Footer/Footer.tsx
Normal file
11
src/pages/home/components/Footer/Footer.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer>
|
||||||
|
<div className='content'>
|
||||||
|
Footer
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
src/pages/home/components/Header/Header.tsx
Normal file
106
src/pages/home/components/Header/Header.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { UserRole, type User } from '../../../../types/user.type'
|
||||||
|
import './header.scss'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import logo from '../../../../assets/img/logo.png'
|
||||||
|
import Search from 'antd/es/input/Search'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import type { StoreType } from '../../../../stores'
|
||||||
|
import type { CartItem } from '../../../../types/cart.type'
|
||||||
|
export default function Header() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const menu = [
|
||||||
|
{
|
||||||
|
iconClass: "fa-solid fa-headphones",
|
||||||
|
text: ["Hotline", "1900.5301"],
|
||||||
|
path: "/hotline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconClass: "fa-solid fa-location-dot",
|
||||||
|
text: ["Hệ thống", "Showroom"],
|
||||||
|
path: "/about"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconClass: "fa-solid fa-clipboard",
|
||||||
|
text: ["Tra cứu", "Đơn hàng"],
|
||||||
|
path: "/order-history"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const userStore = useSelector((store: StoreType) => {
|
||||||
|
return store.user
|
||||||
|
})
|
||||||
|
|
||||||
|
function countCartItem() {
|
||||||
|
return userStore.cart?.reduce((cur: number, next: CartItem) => {
|
||||||
|
return cur + next.quantity
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<div className='content'>
|
||||||
|
<img onClick={() => {
|
||||||
|
navigate("/")
|
||||||
|
}} src={logo} />
|
||||||
|
<i className="fa-solid fa-bars menu-btn"></i>
|
||||||
|
<Search placeholder="input search text" style={{ width: 200 }} />
|
||||||
|
<div className='media_box'>
|
||||||
|
{
|
||||||
|
menu.map((item) => {
|
||||||
|
return (
|
||||||
|
<div onClick={() => {
|
||||||
|
navigate(item.path)
|
||||||
|
}} className='item'>
|
||||||
|
<i className={item.iconClass}></i>
|
||||||
|
<div className='text_box'>
|
||||||
|
<p>{item.text[0]}</p>
|
||||||
|
<p>{item.text[1]}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
<div onClick={() => {
|
||||||
|
window.location.href = "/cart"
|
||||||
|
}} className='item'>
|
||||||
|
<i className="fa-solid fa-cart-shopping"></i>
|
||||||
|
<p className='cart_count'>{countCartItem()}</p>
|
||||||
|
<div className='text_box'>
|
||||||
|
<p>Giỏ</p>
|
||||||
|
<p>Hàng</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='user_box'>
|
||||||
|
{
|
||||||
|
userStore.data ? (
|
||||||
|
<div className='login'>
|
||||||
|
{userStore.data.displayName}
|
||||||
|
{(userStore.data.role == UserRole.ADMIN || userStore.data.role == UserRole.MASTER) && <i onClick={() => {
|
||||||
|
window.location.href = "/admin"
|
||||||
|
}} className="fa-solid fa-lock"></i>}
|
||||||
|
<i onClick={() => {
|
||||||
|
window.localStorage.removeItem("token")
|
||||||
|
window.location.href = "/auth"
|
||||||
|
}} className="cursor-pointer fa-solid fa-right-from-bracket"></i>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='unlogin'>
|
||||||
|
<div className='unlogin-content'>
|
||||||
|
<i className="fa-solid fa-user"></i>
|
||||||
|
<div onClick={() => {
|
||||||
|
navigate("/auth")
|
||||||
|
}} className='text_box'>
|
||||||
|
<p>Đăng</p>
|
||||||
|
<p>Nhập</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
src/pages/home/components/Header/header.scss
Normal file
124
src/pages/home/components/Header/header.scss
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
header {
|
||||||
|
background-color: white;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
color: red;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media_box {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 2em;
|
||||||
|
color: red;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
i {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text_box {
|
||||||
|
font-size: 1.5em;
|
||||||
|
color: red;
|
||||||
|
|
||||||
|
p {
|
||||||
|
&:first-child {}
|
||||||
|
|
||||||
|
&:last-child {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart_count {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 30%;
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
font-size: 1em;
|
||||||
|
background-color: yellow;
|
||||||
|
color: black;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user_box {
|
||||||
|
width: 100px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.login {}
|
||||||
|
|
||||||
|
.unlogin {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
.unlogin-content {
|
||||||
|
background-color: rgba(220, 218, 218, 0.612);
|
||||||
|
padding: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 2em;
|
||||||
|
color: red;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
i {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text_box {
|
||||||
|
font-size: 1.5em;
|
||||||
|
color: red;
|
||||||
|
|
||||||
|
p {
|
||||||
|
&:first-child {}
|
||||||
|
|
||||||
|
&:last-child {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/pages/home/home.scss
Normal file
37
src/pages/home/home.scss
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
$maxWContent: 1080px;
|
||||||
|
$headerH: 60px;
|
||||||
|
$footerH: 120px;
|
||||||
|
|
||||||
|
.home_page {
|
||||||
|
width: 100vw;
|
||||||
|
|
||||||
|
header {
|
||||||
|
width: 100%;
|
||||||
|
height: $headerH;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: $maxWContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
height: calc(100vh - $headerH);
|
||||||
|
.content {
|
||||||
|
width: $maxWContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
height: $footerH;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: $maxWContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/pages/home/product/ProductDetail.tsx
Normal file
82
src/pages/home/product/ProductDetail.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'react-router'
|
||||||
|
import { Apis } from '../../../apis'
|
||||||
|
import type { Product } from '../../../types/product.type'
|
||||||
|
import { Button } from 'antd'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
import type { StoreType } from '../../../stores'
|
||||||
|
import { userAction } from '../../../stores/slices/user.slice'
|
||||||
|
|
||||||
|
export default function ProductDetail() {
|
||||||
|
const { productId } = useParams()
|
||||||
|
const [product, setProduct] = useState<Product | null>(null)
|
||||||
|
const userStore = useSelector((store: StoreType) => store.user)
|
||||||
|
const dipatch = useDispatch()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!productId) return
|
||||||
|
Apis.product.findProductById(productId)
|
||||||
|
.then(res => {
|
||||||
|
setProduct(res)
|
||||||
|
})
|
||||||
|
}, [productId])
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-4 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6 mt-10 bg-white shadow-lg rounded-2xl">
|
||||||
|
<Button onClick={async () => {
|
||||||
|
try {
|
||||||
|
let result = await Apis.cart.addToCart({
|
||||||
|
productId: product.id,
|
||||||
|
userId: userStore.data?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
dipatch(userAction.changeLoad())
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}}>Add To Cart</Button>
|
||||||
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
|
{/* Hình ảnh sản phẩm */}
|
||||||
|
<div className="w-full md:w-1/3 flex justify-center">
|
||||||
|
<img
|
||||||
|
src={product.iconUrl}
|
||||||
|
alt={product.name}
|
||||||
|
className="rounded-xl shadow-md w-full h-auto object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thông tin sản phẩm */}
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800">{product.name}</h1>
|
||||||
|
<p className="text-2xl font-semibold text-blue-600">
|
||||||
|
{product.price.toLocaleString()} ₫
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={`inline-block px-3 py-1 text-sm font-medium rounded-full ${product.isActive
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{product.isActive ? 'Đang bán' : 'Ngừng kinh doanh'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700 mb-2">Mô tả sản phẩm</h2>
|
||||||
|
<div
|
||||||
|
className="prose max-w-none text-gray-600"
|
||||||
|
dangerouslySetInnerHTML={{ __html: product.des }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/pages/home/receipts/ReceiptDetail.tsx
Normal file
9
src/pages/home/receipts/ReceiptDetail.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function ReceiptDetail() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
ReceiptDetail
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
161
src/pages/home/receipts/Receipts.tsx
Normal file
161
src/pages/home/receipts/Receipts.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import type { Receipt } from '../../../types/receipt';
|
||||||
|
import type { StoreType } from '../../../stores';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { Apis } from '../../../apis';
|
||||||
|
import type { Product } from '../../../types/product.type';
|
||||||
|
|
||||||
|
// Define interface for products in receipt (from JSON)
|
||||||
|
interface ReceiptProduct {
|
||||||
|
productId: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Receipts() {
|
||||||
|
const userStore = useSelector((store: StoreType) => store.user);
|
||||||
|
const receipts: Receipt[] = userStore.receipts || [];
|
||||||
|
const [selectedReceipt, setSelectedReceipt] = useState<Receipt | null>(null);
|
||||||
|
const [productDetails, setProductDetails] = useState<Product[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch product details when a receipt is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedReceipt) {
|
||||||
|
setProductDetails([]);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchProductDetails = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const receiptProducts: ReceiptProduct[] = JSON.parse(selectedReceipt.products);
|
||||||
|
const productPromises = receiptProducts.map((item) =>
|
||||||
|
Apis.product.findProductById(item.productId)
|
||||||
|
);
|
||||||
|
const products = await Promise.all(productPromises);
|
||||||
|
setProductDetails(products);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load product details.');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProductDetails();
|
||||||
|
}, [selectedReceipt]);
|
||||||
|
|
||||||
|
const handleViewDetails = (receipt: Receipt) => {
|
||||||
|
setSelectedReceipt(receipt);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-4">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Order List</h1>
|
||||||
|
{receipts.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No orders found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full bg-white border border-gray-200">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100">
|
||||||
|
<th className="py-2 px-4 border-b text-left text-sm font-semibold text-gray-700">Order ID</th>
|
||||||
|
<th className="py-2 px-4 border-b text-left text-sm font-semibold text-gray-700">Progress</th>
|
||||||
|
<th className="py-2 px-4 border-b text-left text-sm font-semibold text-gray-700">Payment Status</th>
|
||||||
|
<th className="py-2 px-4 border-b text-left text-sm font-semibold text-gray-700">Payment Type</th>
|
||||||
|
<th className="py-2 px-4 border-b text-left text-sm font-semibold text-gray-700">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{receipts.map((receipt) => (
|
||||||
|
<tr key={receipt.id} className="hover:bg-gray-50">
|
||||||
|
<td className="py-2 px-4 border-b text-sm text-gray-700">{receipt.id}</td>
|
||||||
|
<td className="py-2 px-4 border-b text-sm text-gray-700">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-1 rounded text-xs font-medium
|
||||||
|
${receipt.progress === 'PENDING' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
receipt.progress === 'ACCEPTED' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
receipt.progress === 'DONE' ? 'bg-green-100 text-green-800' :
|
||||||
|
'bg-red-100 text-red-800'}`}
|
||||||
|
>
|
||||||
|
{receipt.progress}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-4 border-b text-sm text-gray-700">
|
||||||
|
{receipt.isPaid ? (
|
||||||
|
<span className="text-green-600">Paid</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-600">Unpaid</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-4 border-b text-sm text-gray-700">{receipt.payType}</td>
|
||||||
|
<td className="py-2 px-4 border-b text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewDetails(receipt)}
|
||||||
|
className="bg-blue-500 text-white px-3 py-1 rounded hover:bg-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Product Details Section */}
|
||||||
|
{selectedReceipt && (
|
||||||
|
<div className="mt-4 p-4 bg-gray-100 rounded">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Details for Order {selectedReceipt.id}</h2>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500">Loading product details...</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-red-500">{error}</p>
|
||||||
|
) : productDetails.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{productDetails.map((product, index) => {
|
||||||
|
const receiptProduct: ReceiptProduct[] = JSON.parse(selectedReceipt.products);
|
||||||
|
const quantity = receiptProduct.find((item) => item.productId === product.id)?.quantity || 0;
|
||||||
|
return (
|
||||||
|
<div key={product.id} className="flex items-center space-x-4 border-b pb-2">
|
||||||
|
<img
|
||||||
|
src={product.iconUrl}
|
||||||
|
alt={product.name}
|
||||||
|
className="w-16 h-16 object-cover rounded"
|
||||||
|
onError={(e) => (e.currentTarget.src = 'https://via.placeholder.com/64')} // Fallback image
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-sm font-medium">{product.name}</h3>
|
||||||
|
<p className="text-xs text-gray-600">{product.des}</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-medium">Price:</span> ${product.price.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-medium">Quantity:</span> {quantity}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-medium">Total:</span> ${(product.price * quantity).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">No products found for this order.</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedReceipt(null)}
|
||||||
|
className="mt-4 bg-gray-500 text-white px-3 py-1 rounded hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/stores/index.ts
Normal file
18
src/stores/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { combineReducers, configureStore } from "@reduxjs/toolkit";
|
||||||
|
import { userAction, userReducer } from "./slices/user.slice";
|
||||||
|
import { loadingReducer } from "./slices/loading.slice";
|
||||||
|
|
||||||
|
|
||||||
|
const RootReducer = combineReducers({
|
||||||
|
user: userReducer,
|
||||||
|
loading: loadingReducer
|
||||||
|
})
|
||||||
|
|
||||||
|
export type StoreType = ReturnType<typeof RootReducer>
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: RootReducer
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
store.dispatch(userAction.fetchUserData())
|
||||||
14
src/stores/slices/loading.slice.ts
Normal file
14
src/stores/slices/loading.slice.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createSlice } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
|
const loadingSlice = createSlice({
|
||||||
|
name: "loading",
|
||||||
|
initialState: false,
|
||||||
|
reducers: {
|
||||||
|
change: (state) => {
|
||||||
|
state = !state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadingReducer = loadingSlice.reducer
|
||||||
|
export const loadingAction = loadingSlice.actions
|
||||||
83
src/stores/slices/user.slice.ts
Normal file
83
src/stores/slices/user.slice.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
|
||||||
|
import type { User } from "../../types/user.type";
|
||||||
|
import { Apis } from "../../apis";
|
||||||
|
import type { CartItem } from "../../types/cart.type";
|
||||||
|
import type { Receipt } from "../../types/receipt";
|
||||||
|
|
||||||
|
|
||||||
|
interface UserState {
|
||||||
|
data: User | null,
|
||||||
|
loading: boolean;
|
||||||
|
cart: CartItem[],
|
||||||
|
reloadCart: boolean,
|
||||||
|
receipts: Receipt[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const InitUserState: UserState = {
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
cart: [],
|
||||||
|
reloadCart: false,
|
||||||
|
receipts: []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const userSlice = createSlice({
|
||||||
|
name: "user",
|
||||||
|
initialState: InitUserState,
|
||||||
|
reducers: {
|
||||||
|
initCartData: (state, action) => {
|
||||||
|
state.cart = action.payload
|
||||||
|
},
|
||||||
|
initReceiptData: (state, action) => {
|
||||||
|
state.receipts = action.payload
|
||||||
|
},
|
||||||
|
changeLoad: (state) => {
|
||||||
|
state.reloadCart = !state.reloadCart
|
||||||
|
}
|
||||||
|
// addToCart: (state, action) => {
|
||||||
|
// let existedItem = state.cart.find(item => item.productId == action.payload.productId)
|
||||||
|
|
||||||
|
// if(existedItem) {
|
||||||
|
// state.cart = state.cart.map(item => {
|
||||||
|
// if(item.id == existedItem.id) {
|
||||||
|
// return {
|
||||||
|
// ...existedItem,
|
||||||
|
// quantity: action.payload.quantity
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return item
|
||||||
|
// })
|
||||||
|
// }else {
|
||||||
|
// state.cart.push(action.payload)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
extraReducers: (bd) => {
|
||||||
|
bd.addCase(fetchUserData.pending, (state, action) => {
|
||||||
|
state.loading = true
|
||||||
|
})
|
||||||
|
bd.addCase(fetchUserData.fulfilled, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
state.data = action.payload
|
||||||
|
})
|
||||||
|
bd.addCase(fetchUserData.rejected, (state, action) => {
|
||||||
|
state.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const fetchUserData = createAsyncThunk(
|
||||||
|
"user/fetchUserData",
|
||||||
|
async () => {
|
||||||
|
let result = await Apis.user.me(localStorage.getItem("token")) as any
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const userReducer = userSlice.reducer;
|
||||||
|
export const userAction = {
|
||||||
|
...userSlice.actions,
|
||||||
|
fetchUserData
|
||||||
|
}
|
||||||
6
src/types/cart.type.ts
Normal file
6
src/types/cart.type.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface CartItem {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
productId: string;
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
6
src/types/category.type.ts
Normal file
6
src/types/category.type.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
iconUrl: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
9
src/types/product.type.ts
Normal file
9
src/types/product.type.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Product {
|
||||||
|
id: string;
|
||||||
|
categoryId: string;
|
||||||
|
price: number;
|
||||||
|
iconUrl: string;
|
||||||
|
isActive: boolean;
|
||||||
|
name: string;
|
||||||
|
des: string;
|
||||||
|
}
|
||||||
27
src/types/receipt.tsx
Normal file
27
src/types/receipt.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export enum Progress {
|
||||||
|
PENDING = "PENDING", // lúc hóa đơn mới được tạo, đang chờ chủ cửa hàng check
|
||||||
|
ACCEPTED = "ACCEPTED", // đơn đã được shop xác nhận, => chờ shiper
|
||||||
|
CANCEL = "CANCEL", // HỦy
|
||||||
|
DONE = "DONE" // hoàn tất
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PayType {
|
||||||
|
CASH = "CASH",
|
||||||
|
BANK = "BANK"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface Receipt {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
products: string;
|
||||||
|
progress: Progress;
|
||||||
|
isPaid: boolean;
|
||||||
|
payType: PayType;
|
||||||
|
bankCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [{
|
||||||
|
// "productId": 1,
|
||||||
|
// "quantity": 1
|
||||||
|
// }]
|
||||||
24
src/types/user.type.ts
Normal file
24
src/types/user.type.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export enum UserRole {
|
||||||
|
USER = "USER", /* người dùng bình thường, khách hàng, cần người dùng tự đăng ký */
|
||||||
|
MASTER = "MASTER", /* người quản trị cao nhất hệ thống, tạo mặc định */
|
||||||
|
ADMIN = "ADMIN" /* quản trị hệ thống do master tạo ra */
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserStatus {
|
||||||
|
ACTIVE = "ACTIVE", /* dùng bình thường */
|
||||||
|
INACTIVE = "INACTIVE", /* tạm khóa bởi người dùng */
|
||||||
|
BAN = "BAN" /* khóa bởi quản trị viên */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lưu trữ dữ liệu của người dùng và quản trị hệ thống */
|
||||||
|
export interface User {
|
||||||
|
id: string /* id định danh duy nhất cho 1 user */
|
||||||
|
userName: string /* tên đăng nhập */
|
||||||
|
password: string /* mật khẩu đăng nhập */
|
||||||
|
role: UserRole /* vai trò của người trên hệ thống */
|
||||||
|
displayName: string /* tên hiển thị của người dùng trên hệ thống */
|
||||||
|
email: string /* email của người dùng, để gửi thông báo, khôi phục tài khoản,.... */
|
||||||
|
phoneNumber: string /* số điện thoại liên lạc, gửi thông báo, khôi phục tài khoản,... */
|
||||||
|
status: UserStatus /* trạng thái tài khoản */
|
||||||
|
banReason?: string /* lý do bị khóa nếu có */
|
||||||
|
}
|
||||||
13
src/utils/api.util.ts
Normal file
13
src/utils/api.util.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const ApiUtil = {
|
||||||
|
writeQuery: (query: any) => {
|
||||||
|
let resultStr = ``;
|
||||||
|
for(let key in query) {
|
||||||
|
if(query[key] == "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resultStr += `${key}=${query[key]}&`
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultStr.slice(0, resultStr.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/utils/form.util.ts
Normal file
8
src/utils/form.util.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { FormEvent } from "react";
|
||||||
|
|
||||||
|
export const FormUtil = {
|
||||||
|
resetForm: (e: FormEvent) => {
|
||||||
|
let form = (e.target) as any;
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"erasableSyntaxOnly": false,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"noUncheckedSideEffectImports": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": [],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"erasableSyntaxOnly": false,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"noUncheckedSideEffectImports": false
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react({
|
||||||
|
babel: {
|
||||||
|
plugins: [['babel-plugin-react-compiler']],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tailwindcss()
|
||||||
|
],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user