feat: init

master
keyunlei 2024-11-01 15:42:34 +08:00
commit 4bdd10ccec
324 changed files with 26918 additions and 0 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git

11
.env.example Normal file
View File

@ -0,0 +1,11 @@
BASE_URL=http://localhost:3000
MONGODB_URL=mongodb://localhost:27017/choiceshop
NEXT_PUBLIC_ACCESS_TOKEN_SECRET=<your token secret>
NEXT_PUBLIC_ALI_REGION=<your ali endpoint>
NEXT_PUBLIC_ALI_BUCKET_NAME=<your ali bucket name>
NEXT_PUBLIC_ALI_ACCESS_KEY=<your ali access key>
NEXT_PUBLIC_ALI_SECRET_KEY=<your ali secret key>
NEXT_PUBLIC_ALI_ACS_RAM_NAME=<your ali acs:ram name>
NEXT_PUBLIC_ALI_FILES_PATH=<your ali files pathname>

7
.eslintrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": [
"next/core-web-vitals",
"plugin:prettier/recommended",
"prettier" // Add "prettier" last. This will turn off eslint rules conflicting with prettier. This is not what will format our code.
]
}

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

4
.husky/commit-msg Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit ${1}

7
.husky/pre-commit Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
#npm run lint
#npm run format
git add .

5
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/exercise-full-next.iml" filepath="$PROJECT_DIR$/.idea/exercise-full-next.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

10
.lintstagedrc.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = {
// Lint then format TypeScript and JavaScript files
'/**/*.(ts|tsx|js)': filenames => [
`eslint --fix ${filenames.join(' ')}`,
`prettier --write ${filenames.join(' ')}`,
],
// Format MarkDown and JSON
'/**/*.(md|json)': filenames => `prettier --write ${filenames.join(' ')}`,
}

25
.prettierrc.js Normal file
View File

@ -0,0 +1,25 @@
module.exports = {
//前头函数只有一个參数的时候可以忽略括号
arrowParens: 'avoid',
//括号内部不要出现空格
bracketSpacing: true,
//行结東符使用Unix格式
endOfLine: 'lf',
//true:Put>onthelastLineinsteadofatanewline
jsxBracketSameLine: false,
//行宽
printWidth: 100,
//换行方式
proseWrap: 'preserve',
//分号
semi: false,
//使用单引号
singleQuote: true,
//缩进
tabWidth: 2,
//使甩tab缩进
useTabs: false,
//后夏迎号,多行对象、数组在最后一行增加逗号
trailingComma: 'es5',
parser: 'typescript',
}

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}

40
Dockerfile Normal file
View File

@ -0,0 +1,40 @@
FROM node:18-alpine as dependencies
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
FROM node:18-alpine as builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY ./ ./
COPY ./.env ./
RUN npm run build
FROM node:18-alpine as runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --chown=nextjs:nodejs --from=builder /app/.next ./.next
COPY --from=builder /app/.env ./.env
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER nextjs
ENV MONGODB_URL "mongodb://db:27017/choiceshop"
EXPOSE 3000
# RUN addgroup --system --gid 1001 nodejs
# RUN adduser --system --uid 1001 nextjs
# RUN mkdir -p .next/cache/fetch-cache
# RUN chown -R nextjs:nodejs .next
# USER nextjs
# CMD echo 'Waiting for db service start...' && while ! nc -z db 27017; do sleep 1; done; echo 'Connected!' && npm run build && npm run start
# CMD npm run start
CMD ["node_modules/.bin/next", "start"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Jipeng Huang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

263
README.md Normal file
View File

@ -0,0 +1,263 @@
<p align="center">
<img alt="logo" src="https://www.cheerspublishing.com/uploads/article/3ce26e55-1e14-4e51-aec1-1c18533f953c.png" width="300">
</p>
<h1 align="center" style="margin: 30px 0 30px; font-weight: bold;">C-Shopping v1.0.0</h1>
## README.md
- en [English](README.md)
- zh_CN [Simplified Chinese](README.zh_CN.md)
Hello, everyone! Welcome to C-Shopping, a journey into the world of e-commerce unveiling the technological wonders. I am "Ji Xiaopeng," the open-source author of C-Shopping, and today, I will introduce you to an open-source e-commerce platform based on the latest technologies. Let's explore together!
**Project Live Demo Links:**
- Docker Deployment Address: [http://shop.huanghanlian.com/](http://shop.huanghanlian.com/)
- Vercel Address: [https://c-shopping-three.vercel.app/](https://c-shopping-three.vercel.app/)
Project gateway: [https://github.com/huanghanzhilian/c-shopping](https://github.com/huanghanzhilian/c-shopping).
**React Native mobile app application:**
Project gateway: [https://github.com/huanghanzhilian/c-shopping-rn](https://github.com/huanghanzhilian/c-shopping-rn).
If you find this helpful, please give me a Star. It will be a great encouragement.
---
## Project Background
![Project Background](https://www.cheerspublishing.com/uploads/article/c1f2d5ba-6fa9-4994-9f9d-c4f49497fd0c.jpeg)
**Background:**
- Traditional front-end UI frameworks have long been constrained by fixed forms (limited by traditional UI frameworks), leading to visual fatigue. When developing highly customized projects, there is often a sense of powerlessness.
- Excellent web projects with multi-device adaptation are rare, with high learning and maintenance costs.
- As projects become complex, dealing with multiple API calls in components can become complicated. For example, managing multiple loading and error states can lead to a declaration of numerous states. Issues like request cancellation and request race conditions are also prone to being overlooked.
- As the project complexity grows, the development and maintenance of styles become extensive and cumbersome.
**Intent:**
Address the issues mentioned in the background.
**Objective:**
Build a complete, well-designed ecosystem suitable for the web.
---
Firstly, let's delve into the technology behind C-Shopping. I have adopted a series of cutting-edge technologies, including Next.js, Tailwind CSS, Headless UI, Redux-Toolkit-RTK Query, JWT, and Docker, among others. This ensures that this project is not only efficient but also highly scalable. We are committed to addressing some pain points of traditional e-commerce platforms: lack of aesthetics, inadequate adaptation to different devices, and a monotonous interface, among others. By adopting the latest technologies and design principles, C-Shopping creates a fully responsive technical development experience for users.
C-Shopping prioritizes user experience. Our interface is not only beautiful but also responsive, allowing users to enjoy shopping easily on any device. The personal center and order management functions also make your shopping experience more personalized and convenient.
---
## Project Highlights
One of the highlights of C-Shopping is the adoption of a series of advanced technologies, including Next.js, Tailwind CSS, Headless UI, Redux-Toolkit-RTK Query, etc., providing users with an ultimate performance and experience. We not only focus on aesthetics but also strive for excellence in technology.
**Next.js Driven Lightning-Fast Experience**
C-Shopping uses Next.js, meaning not only is the webpage loading speed incredibly fast, but it also supports server-side rendering, providing an unprecedented level of smoothness.
🎨 **Tailwind CSS Stylish Design**
By using Tailwind CSS, C-Shopping injects a sense of style. Each interface is exquisite, making shopping a visual feast.
🔧 **Headless UI Freedom and Flexibility**
C-Shopping opts for the Headless UI style, giving users more freedom during the shopping process. No longer confined to traditional UI frameworks, it opens more doors for customization.
🔐 **JWT Security Without Worries**
Security is paramount! JWT is used for user authentication, providing the strongest guarantee for your shopping behavior, allowing you to shop with confidence.
🐳 **Docker Perfect Deployment**
C-Shopping embraces Docker, making project deployment incredibly simple. Containerization allows the entire project to run seamlessly in different environments.
🔄 **Redux Toolkit and RTK Query State Management Art**
C-Shopping uses Redux Toolkit and RTK Query, making state management more relaxed and enjoyable. You can better track data flow in the application, ensuring the stability of the shopping experience.
---
## Feature Demo
Now, let's take a look at some basic features of C-Shopping. From clear navigation and product displays to convenient search and shopping cart features, every detail has been carefully designed to provide users with a pleasant shopping experience.
**User-side**
| Module| Desktop devices| Mobile devices|
|--|------------|--|
| Home| <img src="https://www.cheerspublishing.com/uploads/article/901edcbd-b143-4f33-9d35-74fda6dbcb0d.gif" /> | <img src="https://www.cheerspublishing.com/uploads/article/cb1e4f8f-aab4-4b83-8cf5-13558bb8f6dc.gif" /> |
| Secondary Category | <img src="https://www.cheerspublishing.com/uploads/article/6b53db16-d55b-4c7b-8088-fb637aad3921.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/542c8bf9-344a-4c19-a9e3-27bc0ec92bd5.png" /> |
| Third-level Category | <img src="https://www.cheerspublishing.com/uploads/article/94ca43fa-3381-45a5-a5bf-80499533f3d5.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/f90b95ba-4b43-48fa-bf70-7736fcc7f9c5.png" /> |
| Product Details | <img src="https://www.cheerspublishing.com/uploads/article/183dd238-2f33-48b3-85f6-d917bf78ba01.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/eb4ae7db-c490-4af2-a99c-d0b10fd6c01e.png" /> |
| Login | <img src="https://www.cheerspublishing.com/uploads/article/e9a0ce6a-f1e9-4b5d-a03e-236338243e48.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/3ec1a909-294c-40d5-98dd-8c890cd8eba2.png" /> |
| Register | <img src="https://www.cheerspublishing.com/uploads/article/5070ac14-4ae8-4eae-9491-27dc33db693f.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/88d32659-0a3d-453c-8f2d-daa8e4ee1b14.png" /> |
| Search | <img src="https://www.cheerspublishing.com/uploads/article/375b23ff-c493-498a-9ca5-e42d1e15e4c9.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/52518186-e141-4614-8da8-c38a31c7895b.png" /> |
| Shopping Cart | <img src="https://www.cheerspublishing.com/uploads/article/233ee4fb-e1ca-4716-ba5f-17224bb252bf.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/17578ef8-1af0-4b03-9942-f5f805d9045b.png" /> |
| Checkout | <img src="https://www.cheerspublishing.com/uploads/article/2cc56a0c-f2b1-4f4c-9bd0-1aea5d4a36a5.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/334c73aa-db17-4624-8e0f-c7ea44236974.png" /> |
| User Profile | <img src="https://www.cheerspublishing.com/uploads/article/3d1db865-9b6b-4c4d-8803-9b94444def73.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/a671fef1-401c-4c3d-9a1d-6cd59ecb9d63.png" /> |
| My Orders | <img src="https://www.cheerspublishing.com/uploads/article/aab4ff6f-50ea-48b8-a74b-dbb6fe178810.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/8114f995-495c-4044-8b6b-2ef2a746d125.png" /> |
| My Reviews | <img src="https://www.cheerspublishing.com/uploads/article/dfa14b9e-2c19-4ea1-b4c9-45483bbc52fe.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/686c0dd9-d9a0-4ff3-9953-eef499349930.png" /> |
| Address Management | <img src="https://www.cheerspublishing.com/uploads/article/1c214382-d281-43b8-87c6-159b9b10e965.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/d4448bfc-40b0-4b18-ae47-c3a9f9884918.png" /> |
| Recent Visits | <img src="https://www.cheerspublishing.com/uploads/article/c375fe8d-fb49-45a3-bdfc-8a90de031b25.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/73a67a1d-a9ae-4ded-990a-4ef172671d34.png" /> |
**Admin-side**
| Module | Desktop devices | Mobile devices|
|--|------------|--|
| Login | <img src="https://www.cheerspublishing.com/uploads/article/10fc1ee3-44ec-4380-ba90-6b2d809fb625.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/d3995bbe-df4f-490a-b8df-998932840ab6.png" /> |
| Admin Center | <img src="https://www.cheerspublishing.com/uploads/article/ae09d053-e2df-4176-8470-b063f556069e.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/633169d7-a616-40fc-8970-d79748734873.png" /> |
| User Management | <img src="https://www.cheerspublishing.com/uploads/article/250ee952-3757-42db-8828-60d8142edd4a.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/ad6fa92c-2bda-4391-9c93-e59fdeff59c3.png" /> |
| Category Management | <img src="https://www.cheerspublishing.com/uploads/article/f644d10f-bda4-4309-944c-587dbe3e8931.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/458eb6ab-2c88-4654-8262-81dffe0b3c66.png" /> |
| Category Management Tree | <img src="https://www.cheerspublishing.com/uploads/article/8eef2702-c06b-4996-bd15-229a3ccb6e2d.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/27516b00-c0e0-4a12-aedc-a9f64f64db1b.png" /> |
| Specification Management | <img src="https://www.cheerspublishing.com/uploads/article/50eb69ce-0545-4def-91e2-ceac09b1222d.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/b96bc0fe-ad45-4b1c-b4d9-945e675cc7b9.png" /> |
| Product Management | <img src="https://www.cheerspublishing.com/uploads/article/893128e7-06e3-47b5-9fb8-8757faf28941.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/1d9b03aa-8673-4405-ad2f-2d61e413c114.png" /> |
| Order Management | <img src="https://www.cheerspublishing.com/uploads/article/e5473ac2-859c-4774-8879-f31516da956a.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/7ac7850b-798c-4954-95ad-3fab562bf418.png" /> |
| Review Management | <img src="https://www.cheerspublishing.com/uploads/article/3979c2fc-87ca-4604-8258-5be1e5af97b9.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/0df0021a-626f-452c-b4dc-d9b0c927d4e3.png" /> |
| Slider Management | <img src="https://www.cheerspublishing.com/uploads/article/6419e018-3322-40f6-b796-105e125d7052.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/b695af32-cd0e-4009-a278-adb2a4f22b2f.png" /> |
| Banner Management | <img src="https://www.cheerspublishing.com/uploads/article/c8fd0a19-f020-41b1-8590-8e88d7d4f659.png" /> | <img src="https://www.cheerspublishing.com/uploads/article/7bc682e2-60c2-45f3-80c3-e94ade1223b2.png" /> |
---
## Project Structure
🏗️ **C-Shopping Project Structure:**
**Key structure explanation:**
- 📁 **app**: Main code of the application
- 📁 **main**: Main application components
- 📁 **client-layout**: Common layout pages for the user side
- 📁 **empty-layout**: Common blank layout pages
- 📁 **admin**: Admin pages
- 📄 **layout.js**: Main layout configuration
- 📁 **profile**: User profile page
- 📄 **StoreProvider.js**: Global state management provider
- 📁 **api**: API request-related routes
- 📁 **auth**: User authentication API
- 📁 **banner**: Advertisement banner API
- 📁 **category**: Product category API
- ...
- 📁 **components**: Reusable React components
- 📁 **helpers**: Helper functions and tools
- 📁 **api**: API request-related helper functions
- 📄 **auth.js**: Helper functions related to user authentication
- ...
- 📁 **hooks**: Custom React hooks
- 📁 **models**: Data model definitions
- 📁 **public**: Static resources, such as images, fonts, etc.
- 📁 **store**: Configuration related to Redux state management
- 📁 **services**: RTK Query
- 📁 **slices**: Redux Toolkit
- 📁 **styles**: Style files
- 📁 **utils**: General utilities
- ...
This structure is designed to make the project organized, easy to maintain, and scalable. Each section is divided based on
functionality and responsibilities, making it easier for team members to understand and collaborate.
---
## Deployment and Usage
**Development Environment**
1. Clone or download the repository by running the following command in the terminal:
```
git clone https://github.com/huanghanzhilian/c-shopping.git
```
2. Install project dependencies using npm or yarn:
```
npm install
```
or
```
yarn
```
3. Please create a new `.env` file from `.env.example` file in the project root directory to define the required environment variables. This step is crucial (for image upload to OSS):
```
NEXT_PUBLIC_ACCESS_TOKEN_SECRET=<your token secret>
NEXT_PUBLIC_ALI_REGION=<your ali endpoint>
NEXT_PUBLIC_ALI_BUCKET_NAME=<your ali bucket name>
NEXT_PUBLIC_ALI_ACCESS_KEY=<your ali access key>
NEXT_PUBLIC_ALI_SECRET_KEY=<your ali secret key>
NEXT_PUBLIC_ALI_ACS_RAM_NAME=<your ali acs:ram name>
NEXT_PUBLIC_ALI_FILES_PATH=<your ali files pathname>
```
4. Install MongoDB on your local machine.
5. Run the project:
```
npm run dev
```
6. Register an account:
```
http://localhost:3000/register
```
7. After creating an account, find your account in the database and modify the `root` field to true and the `role` field to admin. This grants you access to all admin dashboard features:
```
mongo
```
```
use choiceshop
```
```
db.users.update({name:"admin"},{$set:{role:"admin"}})
db.users.update({name:"admin"},{$set:{root:true}})
```
Administrator entrance: http://localhost:3000/admin
8. In MongoDB, create the root category:
```
mongo
```
```
use choiceshop
```
```
db.categories.insert({
"name" : "Featured Items",
"slug" : "choiceshop",
"image" : "http://huanghanzhilian-test.oss-cn-beijing.aliyuncs.com/shop/upload/image//icons/zHle_bmdM_dhu2K938MMM.webp",
"colors" : {
"start" : "#EF394E",
"end" : "#EF3F55"
},
"level" : 0
})
```
**Docker Deployment**
The project root directory is already configured with Docker Compose. After installing Docker, simply run the deployment:
```
docker compose up -d --build
```
---

287
README.zh_CN.md Normal file
View File

@ -0,0 +1,287 @@
<p align="center">
<img alt="logo" src="https://www.cheerspublishing.com/uploads/article/3ce26e55-1e14-4e51-aec1-1c18533f953c.png" width="300">
</p>
<h1 align="center" style="margin: 30px 0 30px; font-weight: bold;">C-Shopping v1.0.0</h1>
<h4 align="center">基于Nextjs开发同时适配Desktop、Tablet、Phone多种设备的精美购物平台</h4>
## README.md
- en [English](README.md)
- zh_CN [简体中文](README.zh_CN.md)
## 前言
`c-shopping`是一个精美的web电商系统支持响应式交互界面优雅功能丰富小巧迅速包含一个电商平台MVP完整功能具备良好的审美风格与编码设计。
希望来的人,有所收获。故事不结束,青春不散场。
## 项目在线演示
**项目在线演示地址:**
- docker 部署地址:[http://shop.huanghanlian.com/](http://shop.huanghanlian.com/)
- vercel 部署地址:[https://c-shopping-three.vercel.app/](https://c-shopping-three.vercel.app/)
项目传送门: [https://github.com/huanghanzhilian/c-shopping](https://github.com/huanghanzhilian/c-shopping)
**React Native 移动app应用:**
项目传送门: [https://github.com/huanghanzhilian/c-shopping-rn](https://github.com/huanghanzhilian/c-shopping-rn)
## 项目介绍
**背景:**
- 一直以来前端UI框架被固定形式占据受限于传统的UI框架导致视觉疲劳在开发一些高度自定义的项目时往往力不从心
- 多设备适配的web优秀项目很少学习和维护成本较高
- 当项目复杂后,在组件需要调用多个 api 时会变得复杂起来,比如需要管理多个 loading 和 error 状态,这会导致产生非常多的 state 声明,还有请求取消、请求竞态等可能存在的问题也容易被忽略;
- 随着项目复杂,样式的开发与维护变得庞大且臃肿;
**意图:**
改进背景中提到的问题。
**目的:**
打造一个完整的适合web端的良好生态。
### 使用技术
- NextJs
- TailwindCss
- Headless UI
- MongoDB
- Redux - Toolkit - RTK Query
- JWT
- Docker
### 功能
用户端:
- 登录 JWT认证
- 注册
- 首页分类navBar、banner、slider、特价板块、hot板块、畅销板块
- 搜索
- 二级分类页分类navBar、banner、slider、特价板块、hot板块、畅销板块
- 三级分类页(排序、筛选)
- 商品详情(购物车)
- 购物车页
- 支付页
- 个人中心
- 我的订单
- 我的评论
- 地址管理
- 近期访问
管理端:
- 登录 JWT认证
- 注册
- 用户管理
- 分类管理
- 规格管理
- 商品管理
- 订单管理
- 评论管理
- 滑块管理
- banner管理
### 演示图
#### 用户端
|模块|Desktop devices|Mobile devices|
|--|------------|--|
|首页|<img src="https://www.cheerspublishing.com/uploads/article/901edcbd-b143-4f33-9d35-74fda6dbcb0d.gif" />|<img src="https://www.cheerspublishing.com/uploads/article/cb1e4f8f-aab4-4b83-8cf5-13558bb8f6dc.gif" />|
|二级分类|<img src="https://www.cheerspublishing.com/uploads/article/6b53db16-d55b-4c7b-8088-fb637aad3921.png" />|<img src="https://www.cheerspublishing.com/uploads/article/542c8bf9-344a-4c19-a9e3-27bc0ec92bd5.png" />|
|三级分类|<img src="https://www.cheerspublishing.com/uploads/article/94ca43fa-3381-45a5-a5bf-80499533f3d5.png" />|<img src="https://www.cheerspublishing.com/uploads/article/f90b95ba-4b43-48fa-bf70-7736fcc7f9c5.png" />|
|商品详情|<img src="https://www.cheerspublishing.com/uploads/article/183dd238-2f33-48b3-85f6-d917bf78ba01.png" />|<img src="https://www.cheerspublishing.com/uploads/article/eb4ae7db-c490-4af2-a99c-d0b10fd6c01e.png" />|
|登录|<img src="https://www.cheerspublishing.com/uploads/article/e9a0ce6a-f1e9-4b5d-a03e-236338243e48.png" />|<img src="https://www.cheerspublishing.com/uploads/article/3ec1a909-294c-40d5-98dd-8c890cd8eba2.png" />|
|注册|<img src="https://www.cheerspublishing.com/uploads/article/5070ac14-4ae8-4eae-9491-27dc33db693f.png" />|<img src="https://www.cheerspublishing.com/uploads/article/88d32659-0a3d-453c-8f2d-daa8e4ee1b14.png" />|
|搜索|<img src="https://www.cheerspublishing.com/uploads/article/375b23ff-c493-498a-9ca5-e42d1e15e4c9.png" />|<img src="https://www.cheerspublishing.com/uploads/article/52518186-e141-4614-8da8-c38a31c7895b.png" />|
|购物车|<img src="https://www.cheerspublishing.com/uploads/article/233ee4fb-e1ca-4716-ba5f-17224bb252bf.png" />|<img src="https://www.cheerspublishing.com/uploads/article/17578ef8-1af0-4b03-9942-f5f805d9045b.png" />|
|支付页|<img src="https://www.cheerspublishing.com/uploads/article/2cc56a0c-f2b1-4f4c-9bd0-1aea5d4a36a5.png" />|<img src="https://www.cheerspublishing.com/uploads/article/334c73aa-db17-4624-8e0f-c7ea44236974.png" />|
|个人中心|<img src="https://www.cheerspublishing.com/uploads/article/3d1db865-9b6b-4c4d-8803-9b94444def73.png" />|<img src="https://www.cheerspublishing.com/uploads/article/a671fef1-401c-4c3d-9a1d-6cd59ecb9d63.png" />|
|我的订单|<img src="https://www.cheerspublishing.com/uploads/article/aab4ff6f-50ea-48b8-a74b-dbb6fe178810.png" />|<img src="https://www.cheerspublishing.com/uploads/article/8114f995-495c-4044-8b6b-2ef2a746d125.png" />|
|我的评论|<img src="https://www.cheerspublishing.com/uploads/article/dfa14b9e-2c19-4ea1-b4c9-45483bbc52fe.png" />|<img src="https://www.cheerspublishing.com/uploads/article/686c0dd9-d9a0-4ff3-9953-eef499349930.png" />|
|地址管理|<img src="https://www.cheerspublishing.com/uploads/article/1c214382-d281-43b8-87c6-159b9b10e965.png" />|<img src="https://www.cheerspublishing.com/uploads/article/d4448bfc-40b0-4b18-ae47-c3a9f9884918.png" />|
|近期访问|<img src="https://www.cheerspublishing.com/uploads/article/c375fe8d-fb49-45a3-bdfc-8a90de031b25.png" />|<img src="https://www.cheerspublishing.com/uploads/article/73a67a1d-a9ae-4ded-990a-4ef172671d34.png" />|
#### 管理端
|模块|Desktop devices|Mobile devices|
|--|------------|--|
|登录|<img src="https://www.cheerspublishing.com/uploads/article/10fc1ee3-44ec-4380-ba90-6b2d809fb625.png" />|<img src="https://www.cheerspublishing.com/uploads/article/d3995bbe-df4f-490a-b8df-998932840ab6.png" />|
|管理中心|<img src="https://www.cheerspublishing.com/uploads/article/ae09d053-e2df-4176-8470-b063f556069e.png" />|<img src="https://www.cheerspublishing.com/uploads/article/633169d7-a616-40fc-8970-d79748734873.png" />|
|用户管理|<img src="https://www.cheerspublishing.com/uploads/article/250ee952-3757-42db-8828-60d8142edd4a.png" />|<img src="https://www.cheerspublishing.com/uploads/article/ad6fa92c-2bda-4391-9c93-e59fdeff59c3.png" />|
|分类管理|<img src="https://www.cheerspublishing.com/uploads/article/f644d10f-bda4-4309-944c-587dbe3e8931.png" />|<img src="https://www.cheerspublishing.com/uploads/article/458eb6ab-2c88-4654-8262-81dffe0b3c66.png" />|
|分类管理树状|<img src="https://www.cheerspublishing.com/uploads/article/8eef2702-c06b-4996-bd15-229a3ccb6e2d.png" />|<img src="https://www.cheerspublishing.com/uploads/article/27516b00-c0e0-4a12-aedc-a9f64f64db1b.png" />|
|规格管理|<img src="https://www.cheerspublishing.com/uploads/article/50eb69ce-0545-4def-91e2-ceac09b1222d.png" />|<img src="https://www.cheerspublishing.com/uploads/article/b96bc0fe-ad45-4b1c-b4d9-945e675cc7b9.png" />|
|商品管理|<img src="https://www.cheerspublishing.com/uploads/article/893128e7-06e3-47b5-9fb8-8757faf28941.png" />|<img src="https://www.cheerspublishing.com/uploads/article/1d9b03aa-8673-4405-ad2f-2d61e413c114.png" />|
|订单管理|<img src="https://www.cheerspublishing.com/uploads/article/e5473ac2-859c-4774-8879-f31516da956a.png" />|<img src="https://www.cheerspublishing.com/uploads/article/7ac7850b-798c-4954-95ad-3fab562bf418.png" />|
|评论管理|<img src="https://www.cheerspublishing.com/uploads/article/3979c2fc-87ca-4604-8258-5be1e5af97b9.png" />|<img src="https://www.cheerspublishing.com/uploads/article/0df0021a-626f-452c-b4dc-d9b0c927d4e3.png" />|
|滑块管理|<img src="https://www.cheerspublishing.com/uploads/article/6419e018-3322-40f6-b796-105e125d7052.png" />|<img src="https://www.cheerspublishing.com/uploads/article/b695af32-cd0e-4009-a278-adb2a4f22b2f.png" />|
|banner管理|<img src="https://www.cheerspublishing.com/uploads/article/c8fd0a19-f020-41b1-8590-8e88d7d4f659.png" />|<img src="https://www.cheerspublishing.com/uploads/article/7bc682e2-60c2-45f3-80c3-e94ade1223b2.png" />|
### 项目结构
🏗️ **C-Shopping 项目结构:**
```
📂 c-shopping
├── 📁 app
│ ├── 📁 main
│ │ ├── 📁 client-layout
│ │ ├── 📁 empty-layout
│ │ ├── 📁 admin
│ │ ├── 📄 layout.js
│ │ └── 📁 profile
│ ├── 📄 StoreProvider.js
│ ├── 📁 api
│ │ ├── 📁 auth
│ │ ├── 📁 banner
│ │ ├── 📁 category
│ │ ├── 📁 details
│ │ ├── 📁 order
│ │ ├── 📁 products
│ │ ├── 📁 reviews
│ │ ├── 📁 slider
│ │ ├── 📁 upload
│ │ └── 📁 user
│ ├── 📄 layout.js
│ └── 📄 not-found.js
├── 📄 commitlint.config.js
├── 📁 components
├── 📄 docker-compose.yml
├── 📁 helpers
│ ├── 📁 api
│ ├── 📄 auth.js
│ ├── 📁 db-repo
│ ├── 📄 db.js
│ ├── 📄 getQuery.js
│ └── 📄 index.js
├── 📁 hooks
├── 📄 jsconfig.json
├── 📁 models
├── 📄 next.config.js
├── 📄 package-lock.json
├── 📄 package.json
├── 📄 postcss.config.js
├── 📂 public
├── 📁 store
├── 📁 styles
├── 📄 tailwind.config.js
└── 📁 utils
```
**主要结构解释:**
- 📁 **app**: 应用程序的主要代码
- 📁 **main**: 主要应用程序组件
- 📁 **client-layout**: 用户端通用布局页面
- 📁 **empty-layout**: 通用空白布局页面
- 📁 **admin**: 管理端页面
- 📄 **layout.js**: 主要布局配置
- 📁 **profile**: 用户个人资料页面
- 📄 **StoreProvider.js**: 全局状态管理提供者
- 📁 **api**: API 请求相关路由
- 📁 **auth**: 用户认证 API
- 📁 **banner**: 广告横幅 API
- 📁 **category**: 商品分类 API
- ...
- 📁 **components**: 可复用的 React 组件
- 📁 **helpers**: 辅助函数和工具
- 📁 **api**: API 请求相关的辅助函数
- 📄 **auth.js**: 用户认证相关的辅助函数
- ...
- 📁 **hooks**: 自定义 React hooks
- 📁 **models**: 数据模型定义
- 📁 **public**: 静态资源,如图片、字体等
- 📁 **store**: Redux 状态管理相关配置
- 📁 **services**: RTK Query
- 📁 **slices**: Redux Toolkit
- 📁 **styles**: 样式文件
- 📁 **utils**: 通用工具
- ...
这个结构旨在使项目组织有序,易于维护和扩展。每个部分都按照功能和职责进行划分,使团队成员更容易理解和协作。
## 环境搭建与部署
### 开发环境
1. 通过在终端运行以下命令克隆或下载存储库:
```
git clone https://github.com/huanghanzhilian/c-shopping.git
```
2. 使用npm或yarn安装项目依赖项:
```
npm install
```
or
```
yarn
```
3. 查看`.env.example`内容,创建新的`.env`的文件在项目根目录定义所需的环境变量。这个步骤是重要的图片上传OSS:
```
NEXT_PUBLIC_ACCESS_TOKEN_SECRET=<your token secret>
NEXT_PUBLIC_ALI_REGION=<your ali endpoint>
NEXT_PUBLIC_ALI_BUCKET_NAME=<your ali bucket name>
NEXT_PUBLIC_ALI_ACCESS_KEY=<your ali access key>
NEXT_PUBLIC_ALI_SECRET_KEY=<your ali secret key>
NEXT_PUBLIC_ALI_ACS_RAM_NAME=<your ali acs:ram name>
NEXT_PUBLIC_ALI_FILES_PATH=<your ali files pathname>
```
4. 在本地机器上安装MongoDB
5. 运行项目
```
npm run dev
```
6. 注册一个账户
```
http://localhost:3000/register
```
7. 创建帐户后在数据库中找到您的帐户并将root字段修改为true。role字段修改为admin这将授予您访问所有管理仪表板功能的权限
```
mongo
```
```
use choiceshop
```
```
db.users.update({name:"admin"},{$set:{role:"admin"}})
db.users.update({name:"admin"},{$set:{root:true}})
```
管理员入口http://localhost:3000/admin
8. 操作MongoDB创建根分类
```
mongo
```
```
use choiceshop
```
```
db.categories.insert({
"name" : "精选好物",
"slug" : "choiceshop",
"image" : "http://huanghanzhilian-test.oss-cn-beijing.aliyuncs.com/shop/upload/image//icons/zHle_bmdM_dhu2K938MMM.webp",
"colors" : {
"start" : "#EF394E",
"end" : "#EF3F55"
},
"level" : 0
})
```
### docker 部署
项目根目录已经配置好docker compose在安装docker环境后直接运行部署
```
docker compose up -d --build
```

View File

@ -0,0 +1,9 @@
import { siteTitle } from '@/utils'
export const metadata = {
title: `购物车-${siteTitle}`,
}
export default function Layout({ children }) {
return <>{children}</>
}

View File

@ -0,0 +1,141 @@
'use client'
import { Fragment } from 'react'
import { useRouter } from 'next/navigation'
import { clearCart } from 'store'
import {
Icons,
FreeShipping,
CartItem,
CartInfo,
Header,
RedirectToLogin,
Button,
EmptyCart,
} from 'components'
import { Menu, Transition } from '@headlessui/react'
import { formatNumber } from 'utils'
import { useUserInfo, useDisclosure, useAppSelector, useAppDispatch } from 'hooks'
const CartPage = () => {
//? Assets
const dispatch = useAppDispatch()
const { push } = useRouter()
const [isShowRedirectModal, redirectModalHandlers] = useDisclosure()
//? Get User Data
const { userInfo } = useUserInfo()
//? Store
const { cartItems, totalItems, totalPrice, totalDiscount } = useAppSelector(state => state.cart)
//? Handlers
const handleRoute = () => {
if (!userInfo) return redirectModalHandlers.open()
push('/checkout/shipping')
}
//? Local Components
const DeleteAllDropDown = () => (
<Menu as="div" className="dropdown">
<Menu.Button className="dropdown__button">
<Icons.More className="icon" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="w-32 dropdown__items ">
<Menu.Item>
<button onClick={() => dispatch(clearCart())} className="px-4 py-3 flex-center gap-x-2">
<Icons.Delete className="icon" />
<span>删除全部</span>
</button>
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
)
//? Render(s)
if (cartItems.length === 0)
return (
<>
<section className="py-2 mx-auto mb-20 space-y-3 xl:mt-36 lg:mb-0 container lg:px-5 lg:mt-6 lg:space-y-0 lg:py-4 lg:border lg:border-gray-200 lg:rounded-md">
<div className="section-divide-y" />
<div className="py-20">
<EmptyCart className="mx-auto h-52 w-52" />
<p className="text-base font-bold text-center">您的购物车是空的</p>
</div>
</section>
</>
)
return (
<>
<RedirectToLogin
title="您还没有登录"
text=""
onClose={redirectModalHandlers.close}
isShow={isShowRedirectModal}
/>
<main className="container py-2 mx-auto mb-20 space-y-3 xl:mt-36 lg:py-0 lg:mb-0 b lg:px-5 lg:mt-6 lg:gap-x-3 lg:flex lg:flex-wrap lg:space-y-0">
<div className="lg:py-4 lg:border lg:border-gray-200 lg:rounded-md lg:flex-1 h-fit">
{/* title */}
<section className="flex justify-between px-4">
<div>
<h3 className="mb-2 text-sm font-bold">您的购物车</h3>
<span className="">{formatNumber(totalItems)} 件商品</span>
</div>
<DeleteAllDropDown />
</section>
{/* carts */}
<section className="divide-y">
{cartItems.map(item => (
<CartItem item={item} key={item.itemID} />
))}
</section>
</div>
<div className="section-divide-y lg:hidden" />
{/* cart Info */}
<section className="lg:sticky lg:top-6 lg:h-fit xl:top-36">
<div className="lg:border lg:border-gray-200 lg:rounded-md">
<CartInfo handleRoute={handleRoute} cart />
</div>
<FreeShipping />
</section>
{/* to Shipping */}
<section className="fixed bottom-0 left-0 right-0 z-10 flex items-center justify-between px-3 py-3 bg-white border-t border-gray-300 shadow-3xl lg:hidden">
<div>
<span className="font-light">总计购物车</span>
<div className="flex items-center">
<span className="text-sm">{formatNumber(totalPrice - totalDiscount)}</span>
<span className="ml-1">¥</span>
</div>
</div>
<Button className="w-1/2" onClick={handleRoute}>
继续
</Button>
</section>
</main>
</>
)
}
export default CartPage

View File

@ -0,0 +1,9 @@
import { siteTitle } from '@/utils'
export const metadata = {
title: `付款-${siteTitle}`,
}
export default function Layout({ children }) {
return <>{children}</>
}

View File

@ -0,0 +1,238 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { clearCart, showAlert } from 'store'
import { useCreateOrderMutation } from '@/store/services'
import {
Button,
CartInfo,
HandleResponse,
Icons,
ResponsiveImage,
WithAddressModal,
} from 'components'
import { formatNumber } from 'utils'
import { useAppDispatch, useAppSelector, useUserInfo } from 'hooks'
const ShippingPage = () => {
//? Assets
const router = useRouter()
const dispatch = useAppDispatch()
//? Get User Data
const { userInfo } = useUserInfo()
//? States
const [paymentMethod, setPaymentMethod] = useState('在线支付')
//? Store
const { cartItems, totalItems, totalDiscount, totalPrice } = useAppSelector(state => state.cart)
//? Create Order Query
const [postData, { data, isSuccess, isError, isLoading, error }] = useCreateOrderMutation()
//? Handlers
const handleCreateOrder = () => {
if (
!userInfo?.address?.city &&
!userInfo?.address?.province &&
!userInfo?.address?.area &&
!userInfo?.address?.street &&
!userInfo?.address?.postalCode
)
return dispatch(
showAlert({
status: 'error',
title: '请填写您的地址',
})
)
else
postData({
body: {
address: {
city: userInfo.address.city.name,
area: userInfo.address.area.name,
postalCode: userInfo.address.postalCode,
provinces: userInfo.address.province.name,
street: userInfo.address.street,
},
mobile: userInfo.mobile,
cart: cartItems,
totalItems,
totalPrice,
totalDiscount,
paymentMethod,
},
})
}
//? Local Components
const ChangeAddress = () => {
const BasicChangeAddress = ({ addressModalProps }) => {
const { openAddressModal } = addressModalProps || {}
return (
<button type="button" onClick={openAddressModal} className="flex items-center ml-auto">
<span className="text-base text-sky-500">改变 | 编辑</span>
<Icons.ArrowRight2 className="icon text-sky-500" />
</button>
)
}
return (
<WithAddressModal>
<BasicChangeAddress />
</WithAddressModal>
)
}
//? Render(s)
return (
<>
{/* Handle Create Order Response */}
{(isSuccess || isError) && (
<HandleResponse
isError={isError}
isSuccess={isSuccess}
error={error?.data?.message}
message={data?.message}
onSuccess={() => {
dispatch(clearCart())
router.push('/profile')
}}
/>
)}
<main className="py-2 mx-auto space-y-3 xl:mt-28 container">
{/* header */}
<header className="lg:border lg:border-gray-200 lg:rounded-lg py-2">
<div className="flex items-center justify-evenly">
<Link href="/checkout/cart" className="flex flex-col items-center gap-y-2">
<Icons.Cart className="text-red-300 icon" />
<span className="font-normal text-red-300">购物车</span>
</Link>
<div className="h-[1px] w-8 bg-red-300" />
<div className="flex flex-col items-center gap-y-2">
<Icons.Wallet className="w-6 h-6 text-red-500 icon" />
<span className="text-base font-normal text-red-500">付款方式</span>
</div>
</div>
</header>
<div className="section-divide-y lg:hidden" />
<div className="lg:flex lg:gap-x-3">
<div className="lg:flex-1">
{/* address */}
<section className="flex items-center px-3 py-4 lg:border lg:border-gray-200 lg:rounded-lg gap-x-3">
<Icons.Location2 className="text-black w-7 h-7" />
<div className="space-y-2">
<span className="">订单送货地址</span>
<p className="text-base text-black">{userInfo?.address?.street}</p>
<span className="text-sm">{userInfo?.name}</span>
</div>
<ChangeAddress />
</section>
<div className="section-divide-y lg:hidden" />
{/* products */}
<section className="px-2 py-4 mx-3 border border-gray-200 rounded-lg lg:mx-0 lg:mt-3 ">
<div className="flex mb-5">
<Image src="/icons/car.png" className="mr-4" width={40} height={40} alt="icon" />
<div>
<span className="text-base text-black">正常发货</span>
<span className="block">有现货</span>
</div>
<span className="inline-block px-2 py-1 ml-3 bg-gray-100 rounded-lg h-fit">
{formatNumber(totalItems)} 件商品
</span>
</div>
<div className="flex flex-wrap justify-start gap-x-8 gap-y-5">
{cartItems.map(item => (
<article key={item.itemID}>
<ResponsiveImage dimensions="w-28 h-28" src={item.img.url} alt={item.name} />
{item.color && (
<div className="flex items-center gap-x-2 ml-3 mt-1.5">
<span
className="inline-block w-4 h-4 shadow rounded-xl"
style={{ background: item.color.hashCode }}
/>
<span>{item.color.name}</span>
</div>
)}
{item.size && (
<div className="flex items-center gap-x-2">
<Icons.Rule className="icon" />
<span>{item.size.size}</span>
</div>
)}
</article>
))}
</div>
<Link href="/checkout/cart" className="inline-block mt-6 text-sm text-sky-500">
返回购物车
</Link>
</section>
</div>
<div className="section-divide-y lg:hidden" />
{/* cart info */}
<section className="lg:border lg:border-gray-200 lg:rounded-md lg:h-fit">
<CartInfo />
<div className="px-3 py-2 space-y-3">
<div className="flex items-center gap-x-2 ">
<input
type="radio"
name="cash"
id="cash"
value="在线支付"
checked={paymentMethod === '在线支付'}
onChange={e => setPaymentMethod(e.target.value)}
/>
<label className="text-sm" htmlFor="cash">
在线支付
</label>
</div>
<div className="flex items-center gap-x-2 ">
<input
type="radio"
name="zarinPal"
id="zarinPal"
value="银行卡"
checked={paymentMethod === '银行卡'}
onChange={e => setPaymentMethod(e.target.value)}
/>
<label className="text-sm" htmlFor="zarinPal">
银行卡
</label>
</div>
<Button
onClick={handleCreateOrder}
isLoading={isLoading}
className="w-full max-w-5xl mx-auto"
>
完成购买
</Button>
</div>
</section>
</div>
</main>
</>
)
}
export default ShippingPage

View File

@ -0,0 +1,33 @@
'use client' // Error components must be Client Components
import { Button } from '@/components'
import { useEffect } from 'react'
export default function Error({ error, reset }) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
return (
<>
<main className="lg:px-3 container xl:mt-32">
<div className="py-20 mx-auto space-y-3 text-center w-fit">
<h5 className="text-xl">{error.name}</h5>
<p className="text-lg text-red-500">出现异常请检查您的地址是否有误或者联系管理员</p>
<Button
className="mx-auto"
onClick={
// Attempt to recover by trying to re-render the segment
() => {
console.log('发送异常警报通知到OA系统', error.message)
}
}
>
通知我们
</Button>
</div>
</main>
</>
)
}

View File

@ -0,0 +1,9 @@
import { ClientLayout } from 'components'
export default function Layout({ children }) {
return (
<>
<ClientLayout>{children}</ClientLayout>
</>
)
}

View File

@ -0,0 +1,92 @@
import { db } from '@/helpers'
import { Category, Banner, Slider } from 'models'
import {
BannerOne,
BannerTwo,
BestSellsSlider,
Categories,
DiscountSlider,
MostFavouraiteProducts,
Slider as MainSlider,
} from 'components'
import { siteTitle } from '@/utils'
// export const revalidate = 20
export const dynamic = 'force-dynamic'
export const getData = async category => {
await db.connect()
const currentCategory = await Category.findOne({
slug: category,
}).lean()
if (!currentCategory) return { notFound: true }
const sliders = await Slider.find({ category_id: currentCategory?._id }).lean()
const bannerOneType = await Banner.find({
category_id: currentCategory?._id,
type: 'one',
}).lean()
const bannerTwoType = await Banner.find({
category_id: currentCategory?._id,
type: 'two',
}).lean()
const childCategories = await Category.find({
parent: currentCategory?._id,
}).lean()
await db.disconnect()
return {
currentCategory,
sliders,
bannerOneType,
bannerTwoType,
childCategories,
}
}
const MainCategory = async ({ params: { category } }) => {
const { currentCategory, sliders, bannerOneType, bannerTwoType, childCategories } =
await getData(category)
//? Render(s)
return (
<main className="container min-h-screen space-y-6 xl:mt-28">
<div className="py-4 mx-auto space-y-12 xl:mt-28">
<MainSlider data={sliders} />
<DiscountSlider currentCategory={currentCategory} />
<Categories
childCategories={{ categories: childCategories, title: '所有分类' }}
color={currentCategory.colors?.start}
name={currentCategory.name}
/>
<BannerOne data={bannerOneType} />
<BestSellsSlider categorySlug={currentCategory.slug} />
<BannerTwo data={bannerTwoType} />
<MostFavouraiteProducts categorySlug={currentCategory.slug} />
</div>
</main>
)
}
export default MainCategory
export async function generateMetadata({ params: { category } }) {
const { currentCategory, sliders, bannerOneType, bannerTwoType, childCategories } =
await getData(category)
return {
title: `${currentCategory.name} | ${siteTitle}`,
}
}

View File

@ -0,0 +1,67 @@
// import { Metadata } from 'next'
import { bannerRepo, categoryRepo, sliderRepo } from '@/helpers'
import {
BannerOne,
BannerTwo,
BestSellsSlider,
Categories,
DiscountSlider,
Slider as MainSlider,
MostFavouraiteProducts,
} from '@/components'
import { enSiteTitle, siteTitle } from '@/utils'
export const metadata = {
title: `${siteTitle} | ${enSiteTitle}`,
}
// export const revalidate = 20
export const dynamic = 'force-dynamic'
export default async function Home({ searchParams }) {
const currentCategory = await categoryRepo.getOne({
parent: undefined,
})
const childCategories = await categoryRepo.getAll(
{},
{
parent: currentCategory?._id,
}
)
const sliders = await sliderRepo.getAll({}, { category_id: currentCategory?._id })
const bannerOneType = await bannerRepo.getAll(
{},
{
category_id: currentCategory?._id,
type: 'one',
}
)
const bannerTwoType = await bannerRepo.getAll(
{},
{
category_id: currentCategory?._id,
type: 'two',
}
)
return (
<main className="min-h-screen xl:mt-28 container space-y-24">
<div className="py-4 mx-auto space-y-24 xl:mt-28">
<MainSlider data={sliders} />
<DiscountSlider currentCategory={currentCategory} />
<Categories
childCategories={{ categories: childCategories, title: '所有分类' }}
color={currentCategory?.colors?.start}
name={currentCategory?.name}
homePage
/>
<BannerOne data={bannerOneType} />
<BestSellsSlider categorySlug={currentCategory?.slug} />
<BannerTwo data={bannerTwoType} />
<MostFavouraiteProducts categorySlug={currentCategory?.slug} />
</div>
</main>
)
}

View File

@ -0,0 +1,133 @@
import { Product } from 'models'
import {
FreeShipping,
Services,
SmilarProductsSlider,
ImageGallery,
Description,
Specification,
Reviews,
SelectColor,
SelectSize,
OutOfStock,
AddToCart,
Info,
Breadcrumb,
InitialStore,
} from 'components'
import { db } from '@/helpers'
export const getData = async params => {
await db.connect()
const product = await Product.findById({ _id: params?.id })
.populate('category_levels.level_one')
.populate('category_levels.level_two')
.populate('category_levels.Level_three')
.lean()
if (!product) return { notFound: true }
const productCategoryID = product.category.pop()
const smilarProducts = await Product.find({
category: { $in: productCategoryID },
inStock: { $gte: 1 },
_id: { $ne: product._id },
})
.select(
'-description -info -specification -category -category_levels -sizes -reviews -numReviews'
)
.limit(11)
.lean()
await db.disconnect()
return {
product: JSON.parse(JSON.stringify(product)),
smilarProducts: {
title: '类似商品',
products: JSON.parse(JSON.stringify(smilarProducts)),
},
}
}
const SingleProduct = async ({ params }) => {
const { product, smilarProducts } = await getData(params)
return (
<main className="xl:mt-28 container mx-auto py-4 space-y-4">
<InitialStore product={product} />
<Breadcrumb categoryLevels={product.category_levels} />
<div className="h-fit lg:h-fit lg:grid lg:grid-cols-9 lg:px-4 lg:gap-x-2 lg:gap-y-4 lg:mb-10 xl:gap-x-7">
<ImageGallery
images={product.images}
discount={product.discount}
inStock={product.inStock}
productName={product.title}
/>
<div className="lg:col-span-4 ">
{/* title */}
<h2 className="p-3 text-base font-semibold leading-8 tracking-wide text-black/80 ">
{product.title}
</h2>
<div className="section-divide-y" />
{product.inStock > 0 && product.colors.length > 0 && (
<SelectColor colors={product.colors} />
)}
{product.inStock > 0 && product.sizes.length > 0 && <SelectSize sizes={product.sizes} />}
{product.inStock === 0 && <OutOfStock />}
<Info infos={product?.info} />
<FreeShipping />
</div>
<div className="lg:col-span-2">
{product.inStock > 0 && <AddToCart product={product} />}
</div>
</div>
<Services />
{product.description.length > 0 && <Description description={product.description} />}
<SmilarProductsSlider smilarProducts={smilarProducts} />
<div className="section-divide-y" />
<div className="flex">
<div className="flex-1">
<Specification specification={product.specification} />
<div className="section-divide-y" />
<Reviews
numReviews={product.numReviews}
prdouctID={product._id}
productTitle={product.title}
/>
</div>
<div className="hidden w-full px-3 lg:block lg:max-w-xs xl:max-w-sm">
{product.inStock > 0 && <AddToCart product={product} second />}
</div>
</div>
</main>
)
}
export default SingleProduct
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }) {
const { product, smilarProducts } = await getData(params)
return {
title: `购买 ${product.title}`,
description: `${product.title}`,
}
}

View File

@ -0,0 +1,9 @@
import { siteTitle } from '@/utils'
export const metadata = {
title: `分类 ${siteTitle}`,
}
export default function Layout({ children }) {
return <>{children}</>
}

View File

@ -0,0 +1,131 @@
'use client'
import {
ProductCard,
Pagination,
Sort,
ProductsAside,
SubCategories,
Filter,
ProductSkeleton,
} from 'components'
import { useChangeRoute, useMediaQuery } from 'hooks'
import { useUrlQuery } from '@/hooks'
import { useGetCategoriesQuery, useGetProductsQuery } from '@/store/services'
const ProductsHome = () => {
//? Assets
const query = useUrlQuery()
const category = query?.category?.toString() ?? ''
const page_size = query?.page_size?.toString() ?? ''
const page = query?.page?.toString() ?? ''
const sort = query?.sort?.toString() ?? ''
const search = query?.search?.toString() ?? ''
const inStock = query?.inStock?.toString() ?? ''
const discount = query?.discount?.toString() ?? ''
const price = query?.price?.toString() ?? ''
const isDesktop = useMediaQuery('(min-width:1280px)')
//? Handlers
const changeRoute = useChangeRoute()
const handleChangeRoute = newQueries => {
changeRoute({
...query,
page: 1,
...newQueries,
})
}
//? Querirs
//* Get Products Data
const { data, isFetching: isFetchingProduct } = useGetProductsQuery({
category,
page_size,
page,
sort,
search,
inStock,
discount,
price,
})
//* Get childCategories Data
const { isLoading: isLoadingCategories, childCategories } = useGetCategoriesQuery(undefined, {
selectFromResult: ({ isLoading, data }) => {
const currentCategory = data?.data?.categories.find(cat => cat.slug === query?.category)
const childCategories = data?.data?.categories.filter(
cat => cat.parent === currentCategory?._id
)
return { childCategories, isLoading }
},
})
//? Render(s)
return (
<>
<main className="lg:px-3 container xl:mt-32">
<SubCategories childCategories={childCategories} isLoading={isLoadingCategories} />
<div className="px-1 lg:flex lg:gap-x-0 xl:gap-x-3">
<ProductsAside
mainMaxPrice={data?.data?.mainMaxPrice}
mainMinPrice={data?.data?.mainMinPrice}
handleChangeRoute={handleChangeRoute}
/>
<div id="_products" className="w-full p-4 mt-3 ">
{/* Filters & Sort */}
<div className="divide-y-2 ">
<div className="flex py-2 gap-x-3">
{!isDesktop && (
<Filter
mainMaxPrice={data?.mainMaxPrice}
mainMinPrice={data?.mainMinPrice}
handleChangeRoute={handleChangeRoute}
/>
)}
<Sort handleChangeRoute={handleChangeRoute} />
</div>
<div className="flex justify-between py-2">
<span>所有商品</span>
<span className="">{data?.data?.productsLength} 件商品</span>
</div>
</div>
{/* Products */}
{isFetchingProduct ? (
<ProductSkeleton />
) : data && data?.data?.products.length > 0 ? (
<section className="sm:grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{data?.data?.products.map(item => <ProductCard product={item} key={item._id} />)}
</section>
) : (
<section className="text-center text-red-500 xl:border xl:border-gray-200 xl:rounded-md xl:py-4">
没有找到商品
</section>
)}
</div>
</div>
{data && data?.data?.productsLength > 10 && (
<div className="py-4 mx-auto lg:max-w-5xl">
<Pagination
pagination={data?.data?.pagination}
changeRoute={handleChangeRoute}
section="_products"
client
/>
</div>
)}
</main>
</>
)
}
export default ProductsHome

View File

@ -0,0 +1,3 @@
export default function Layout({ children }) {
return <>{children}</>
}

View File

@ -0,0 +1,7 @@
export const metadata = {
title: '登录',
}
export default function LoginLayout({ children }) {
return <>{children}</>
}

View File

@ -0,0 +1,66 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { HandleResponse, LoginForm, Logo } from '@/components'
import { useLoginMutation } from '@/store/services'
import { useDispatch } from 'react-redux'
import { userLogin } from 'store'
export default function LoginPage() {
//? Assets
const dispatch = useDispatch()
const { replace } = useRouter()
const searchParams = useSearchParams()
const redirectTo = searchParams.get('redirectTo')
//? Login User
const [login, { data, isSuccess, isError, isLoading, error }] = useLoginMutation()
//? Handlers
const submitHander = async ({ email, password }) => {
if (email && password) {
await login({
body: { email, password },
})
}
}
return (
<>
{/* Handle Login Response */}
{(isSuccess || isError) && (
<HandleResponse
isError={isError}
isSuccess={isSuccess}
error={error?.data?.message}
message={data?.message}
onSuccess={() => {
dispatch(userLogin(data.data.token))
replace(redirectTo || '/')
}}
/>
)}
<main className="grid items-center min-h-screen">
<section className="container max-w-md px-12 py-6 space-y-6 lg:border lg:border-gray-100 lg:rounded-lg lg:shadow">
<Link passHref href="/">
<Logo className="mx-auto w-48 h-24" />
</Link>
<h1>
<font className="">
<font>登录</font>
</font>
</h1>
<LoginForm isLoading={isLoading} onSubmit={submitHander} />
<div className="text-xs">
<p className="inline mr-2 text-gray-800 text-xs">我还没有账户</p>
<Link href="/register" className="text-blue-400 text-xs">
去注册
</Link>
</div>
</section>
</main>
</>
)
}

View File

@ -0,0 +1,7 @@
export const metadata = {
title: '注册',
}
export default function LoginLayout({ children }) {
return <>{children}</>
}

View File

@ -0,0 +1,138 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
import Link from 'next/link'
import { useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { registerSchema } from 'utils'
import { TextField, LoginBtn, HandleResponse, RedirectToLogin, Logo } from '@/components'
import { useCreateUserMutation } from '@/store/services'
import { useDispatch } from 'react-redux'
import { userLogin } from 'store'
import { useDisclosure } from '@/hooks'
export default function RegisterPage() {
//? Assets
const [isShowRedirectModal, redirectModalHandlers] = useDisclosure()
const dispatch = useDispatch()
const { replace } = useRouter()
const searchParams = useSearchParams()
const redirectTo = searchParams.get('redirectTo')
//? Create User
const [createUser, { data, isSuccess, isError, isLoading, error }] = useCreateUserMutation()
//? Form Hook
const {
handleSubmit,
formState: { errors: formErrors },
reset,
setFocus,
control,
} = useForm({
resolver: yupResolver(registerSchema),
defaultValues: { name: '', email: '', password: '', confirmPassword: '' },
})
//? Focus On Mount
useEffect(() => {
setFocus('name')
}, [])
//? Handlers
const submitHander = async ({ name, email, password }) => {
if (name && email && password) {
await createUser({
body: { name, email, password },
})
}
}
const onError = () => {
if (error.status === 422) {
redirectModalHandlers.open()
}
}
const onSuccess = () => {
dispatch(userLogin(data.data.token))
reset()
replace(redirectTo || '/')
}
return (
<>
<RedirectToLogin
title="注册异常"
text={error?.data?.message}
onClose={redirectModalHandlers.close}
isShow={isShowRedirectModal}
/>
{/* Handle Login Response */}
{(isSuccess || isError) && (
<HandleResponse
isError={isError}
isSuccess={isSuccess}
error={error?.data?.message}
message={data?.message}
onSuccess={onSuccess}
onError={onError}
/>
)}
<main className="grid items-center min-h-screen">
<section className="container max-w-md px-12 py-6 space-y-6 lg:border lg:border-gray-100 lg:rounded-lg lg:shadow">
<Link passHref href="/">
<Logo className="mx-auto w-48 h-24" />
</Link>
<h1>
<font className="">
<font>注册</font>
</font>
</h1>
<form className="space-y-4" onSubmit={handleSubmit(submitHander)} autoComplete="off">
<TextField
errors={formErrors.name}
placeholder="请输入您的账户名称"
name="name"
control={control}
/>
<TextField
errors={formErrors.email}
placeholder="请输入您的账户邮箱"
name="email"
control={control}
/>
<TextField
errors={formErrors.password}
type="password"
placeholder="请输入您的账户密码"
name="password"
control={control}
/>
<TextField
control={control}
errors={formErrors.confirmPassword}
type="password"
placeholder="确认密码,请再次输入"
name="confirmPassword"
/>
<LoginBtn isLoading={isLoading}>注册</LoginBtn>
</form>
<div className="text-xs">
<p className="inline mr-2 text-gray-800 text-xs">我已经有账户了</p>
<Link href="/login" className="text-blue-400 text-xs">
去登录
</Link>
</div>
</section>
</main>
</>
)
}

View File

@ -0,0 +1,54 @@
'use client'
import { useRouter } from 'next/navigation'
import { BannerForm, HandleResponse, PageContainer } from 'components'
import { SubmitHandler } from 'react-hook-form'
import { useCreateBannerMutation } from '@/store/services'
import { useTitle, useUrlQuery } from '@/hooks'
const CreateBannerPage = () => {
useTitle('新增banner')
//? Assets
const { back } = useRouter()
const query = useUrlQuery()
const categoryId = query?.category_id
//? Queries
//* Create Banner
const [createBanner, { data, isSuccess, isLoading, error, isError }] = useCreateBannerMutation()
//? Handlers
const createHandler = data => {
const { image, isPublic, title, type, uri } = data
createBanner({
body: { category_id: categoryId, image, isPublic, title, type, uri },
})
}
const onSuccess = () => back()
return (
<>
{(isSuccess || isError) && (
<HandleResponse
isError={isError}
isSuccess={isSuccess}
error={error?.data?.message}
message={data?.message}
onSuccess={onSuccess}
/>
)}
<main>
<PageContainer title="新增banner">
<BannerForm mode="create" isLoadingCreate={isLoading} createHandler={createHandler} />
</PageContainer>
</main>
</>
)
}
export default CreateBannerPage

View File

@ -0,0 +1,181 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import {
BannerForm,
BigLoading,
ConfirmDeleteModal,
ConfirmUpdateModal,
HandleResponse,
PageContainer,
} from 'components'
import { useDisclosure } from 'hooks'
import {
useDeleteBannerMutation,
useGetSingleBannerQuery,
useUpdateBannerMutation,
} from '@/store/services'
import { SubmitHandler } from 'react-hook-form'
import { useTitle, useUrlQuery } from '@/hooks'
const EditBannerPage = ({ params: { id: bannerId } }) => {
//? Assets
const { back } = useRouter()
const query = useUrlQuery()
const bannerName = query?.banner_name
const initialUpdataInfo = {}
//? Modals
const [isShowConfirmUpdateModal, confirmUpdateModalHandlers] = useDisclosure()
const [isShowConfirmDeleteModal, confirmDeleteModalHandlers] = useDisclosure()
//? States
const [updateInfo, setUpdateInfo] = useState(initialUpdataInfo)
//? Queries
//* Get Banner
const { data: selectedBanner, isLoading: isLoadingGetSelectedBanner } = useGetSingleBannerQuery({
id: bannerId,
})
//* Update Banner
const [
updateBanner,
{
data: dataUpdate,
isSuccess: isSuccessUpdate,
isError: isErrorUpdate,
error: errorUpdate,
isLoading: isLoadingUpdate,
},
] = useUpdateBannerMutation()
//* Delete Banner
const [
deleteBanner,
{
isSuccess: isSuccessDelete,
isError: isErrorDelete,
error: errorDelete,
data: dataDelete,
isLoading: isLoadingDelete,
},
] = useDeleteBannerMutation()
//? Handlers
//* Update
const updateHandler = data => {
setUpdateInfo(prev => ({ ...prev, ...selectedBanner.data, ...data }))
confirmUpdateModalHandlers.open()
}
const onConfirmUpdate = () => {
updateBanner({
id: bannerId,
body: updateInfo,
})
}
const onCancelUpdate = () => {
setUpdateInfo(initialUpdataInfo)
confirmUpdateModalHandlers.close()
}
const onSuccessUpdate = () => {
setUpdateInfo(initialUpdataInfo)
confirmUpdateModalHandlers.close()
back()
}
const onErrorUpdate = () => {
setUpdateInfo(initialUpdataInfo)
confirmUpdateModalHandlers.close()
}
//* Delete
const deleteHandler = () => confirmDeleteModalHandlers.open()
const onConfirmDelete = () => deleteBanner({ id: bannerId })
const onCancelDelete = () => confirmDeleteModalHandlers.close()
const onSuccessDelete = () => {
confirmDeleteModalHandlers.close()
back()
}
const onErrorDelete = () => confirmDeleteModalHandlers.close()
useTitle('编辑banner' + ' ' + bannerName)
return (
<>
<ConfirmDeleteModal
title="banner"
isLoading={isLoadingDelete}
isShow={isShowConfirmDeleteModal}
onClose={confirmDeleteModalHandlers.close}
onCancel={onCancelDelete}
onConfirm={onConfirmDelete}
/>
{(isSuccessDelete || isErrorDelete) && (
<HandleResponse
isError={isErrorDelete}
isSuccess={isSuccessDelete}
error={errorDelete?.data?.message}
message={dataDelete?.message}
onSuccess={onSuccessDelete}
onError={onErrorDelete}
/>
)}
<ConfirmUpdateModal
title="banner"
isLoading={isLoadingUpdate}
isShow={isShowConfirmUpdateModal}
onClose={confirmUpdateModalHandlers.close}
onCancel={onCancelUpdate}
onConfirm={onConfirmUpdate}
/>
{(isSuccessUpdate || isErrorUpdate) && (
<HandleResponse
isError={isErrorUpdate}
isSuccess={isSuccessUpdate}
error={errorUpdate?.data?.message}
message={dataUpdate?.message}
onSuccess={onSuccessUpdate}
onError={onErrorUpdate}
/>
)}
<main>
<PageContainer title={'编辑banner' + ' ' + bannerName}>
{isLoadingGetSelectedBanner ? (
<div className="px-3 py-20">
<BigLoading />
</div>
) : selectedBanner.data ? (
<BannerForm
mode="edit"
selectedBanner={selectedBanner.data}
updateHandler={updateHandler}
isLoadingDelete={isLoadingDelete}
isLoadingUpdate={isLoadingUpdate}
deleteHandler={deleteHandler}
/>
) : null}
</PageContainer>
</main>
</>
)
}
export default EditBannerPage

View File

@ -0,0 +1,134 @@
'use client'
import Link from 'next/link'
import { useGetBannersQuery, useGetCategoriesQuery } from '@/store/services'
import { useTitle, useUrlQuery } from '@/hooks'
import { ResponsiveImage, EmptyCustomList, PageContainer, TableSkeleton } from '@/components'
const BannersPage = () => {
const query = useUrlQuery()
const category_id = query?.category_id
const category_name = query?.category_name
//? Get Categories
const { categories, isLoading: isLodingGetCategories } = useGetCategoriesQuery(undefined, {
selectFromResult: ({ data, isLoading }) => ({
categories: data?.data?.categories
.filter(category => category.level < 2)
.sort((a, b) => a.level - b.level),
isLoading,
}),
skip: !!category_id,
})
const { data: banners, isLoading: isLoading_get_banners } = useGetBannersQuery(
{ category: category_id },
{
skip: !!!category_id,
}
)
//? Render(s)
const title = category_name ? `banner管理 - ${category_name}` : 'banner管理'
useTitle(title)
const renderContent = () => {
if (isLoading_get_banners || isLodingGetCategories) {
return (
<tr>
<td colSpan="4">
<TableSkeleton />
</td>
</tr>
)
}
if (categories && !category_id) {
return categories.map(category => (
<tr
className="text-xs text-center transition-colors border-b border-gray-100 md:text-sm hover:bg-gray-50/50"
key={category._id}
>
<td className="w-3/4 px-2 py-4">{category.name}</td>
<td className="px-2 py-4">
<Link
href={`/admin/banners?category_id=${category._id}&category_name=${category.name}`}
className="bg-rose-50 text-rose-500 rounded-sm py-1 px-1.5 mx-1.5 inline-block"
>
子集
</Link>
</td>
</tr>
))
}
if (banners?.data && banners?.data?.length > 0) {
return banners?.data.map(banner => (
<tr
className="text-xs text-center transition-colors border-b border-gray-100 md:text-sm hover:bg-gray-50/50"
key={banner._id}
>
<td className="px-2 py-4">
<ResponsiveImage
dimensions={`h-7 ${banner.type === 'one' ? 'w-16' : 'w-10'}`}
className="mx-auto"
src={banner.image?.url}
alt=""
/>
</td>
<td className="px-2 py-4">{banner.title}</td>
<td className="px-2 py-4">{banner.type}</td>
<td className="px-2 py-4">
<Link
href={`/admin/banners/edit/${banner._id}?banner_name=${banner.title}`}
className="bg-rose-50 text-rose-500 rounded-sm py-1 px-1.5 mx-1.5 inline-block"
>
编辑
</Link>
</td>
</tr>
))
} else
return (
<tr>
<td colSpan="4">
<EmptyCustomList />
</td>
</tr>
)
}
return (
<main>
<PageContainer title={title}>
<section className="p-3 mx-auto mb-10 space-y-8">
{category_id && (
<Link
href={`banners/create?category_id=${category_id}&category_name=${category_name}`}
className="flex items-center px-3 py-2 text-red-600 border-2 border-red-600 rounded-lg max-w-max gap-x-3"
>
添加新banner
</Link>
)}
<div className="mx-3 overflow-x-auto mt-7 lg:mx-5 xl:mx-10">
<table className="w-full whitespace-nowrap">
<thead className="h-9 bg-emerald-50">
<tr className="text-emerald-500">
{category_name && <th className="border-gray-100 border-x-2">图片</th>}
<th className="px-2 border-gray-100 border-x-2">
{category_name ? 'banner标题' : '分类名称'}
</th>
{category_name && <th className="border-gray-100 border-x-2">类型</th>}
<th className="border-gray-100 border-x-2">操作</th>
</tr>
</thead>
<tbody className="text-gray-600">{renderContent()}</tbody>
</table>
</div>
</section>
</PageContainer>
</main>
)
}
export default BannersPage

View File

@ -0,0 +1,77 @@
'use client'
import { BigLoading, CategoryForm, HandleResponse, PageContainer } from '@/components'
import { useTitle, useUrlQuery } from '@/hooks'
import { useCreateCategoryMutation, useGetCategoriesQuery } from '@/store/services'
import { useRouter } from 'next/navigation'
export default function CategoriesCreatePage() {
useTitle('创建分类')
//? Assets
const { push } = useRouter()
const query = useUrlQuery()
const parentId = query.parent_id
const parentLvl = query.parent_lvl ? +query.parent_lvl : 0
//? Queries
//* Get Categories
const { isLoading: isLoading_get, parentCategory } = useGetCategoriesQuery(undefined, {
selectFromResult: ({ data, isLoading }) => ({
parentCategory: data?.data?.categories.find(category => category._id === parentId),
isLoading,
}),
})
//* Create Category
const [createCtegory, { data, isSuccess, isLoading, error, isError }] =
useCreateCategoryMutation()
//? Handlers
const createHandler = data => {
const { name, slug, image, colors } = data
createCtegory({
body: {
name,
parent: parentId || '',
slug: slug.trim().split(' ').join('-'),
image,
colors,
level: parentCategory ? parentCategory?.level + 1 : 0,
},
})
}
const onSuccess = () => {
push(`/admin/categories${parentId ? `?parent_id=${parentId}` : ''}`)
}
return (
<>
{(isSuccess || isError) && (
<HandleResponse
isError={isError}
isSuccess={isSuccess}
error={error?.data?.message}
message={data?.data?.message}
onSuccess={onSuccess}
/>
)}
{isLoading_get ? (
<div className="px-3 py-20">
<BigLoading />
</div>
) : (
<main>
<PageContainer title="创建分类">
<CategoryForm
mode="create"
isLoading={isLoading}
parentLvl={parentLvl}
createHandler={createHandler}
/>
</PageContainer>
</main>
)}
</>
)
}

View File

@ -0,0 +1,118 @@
'use client'
import {
CategoryForm,
HandleResponse,
PageContainer,
ConfirmUpdateModal,
BigLoading,
} from '@/components'
import { useDisclosure, useTitle, useUrlQuery } from '@/hooks'
import { useGetCategoriesQuery, useUpdateCategoryMutation } from '@/store/services'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
export default function CategoriesEditPage({ params: { id } }) {
useTitle('编辑分类')
//? Assets
const { push } = useRouter()
const query = useUrlQuery()
const parentId = query.parent_id
const parentLvl = query.parent_lvl ? +query.parent_lvl : 0
//? Modals
const [isShowConfirmUpdateModal, confirmUpdateModalHandlers] = useDisclosure()
//? States
const [updateInfo, setUpdateInfo] = useState({})
//? Queries
//* Get Categories
const { isLoading: isLoading_get, selectedCategory } = useGetCategoriesQuery(undefined, {
selectFromResult: ({ data, isLoading }) => ({
selectedCategory: data?.data?.categories.find(category => category._id === id),
isLoading,
}),
})
//* Update Category
const [
updateCategory,
{
data: data_update,
isSuccess: isSuccess_update,
isError: isError_update,
error: error_update,
isLoading: isLoading_update,
},
] = useUpdateCategoryMutation()
//? Handlers
const updateHandler = data => {
setUpdateInfo(prev => ({ ...prev, ...selectedCategory, ...data }))
confirmUpdateModalHandlers.open()
}
const onConfirm = () => {
updateCategory({
id,
body: updateInfo,
})
}
const onCancel = () => {
setUpdateInfo({})
confirmUpdateModalHandlers.close()
}
const onSuccess = () => {
setUpdateInfo({})
confirmUpdateModalHandlers.close()
push(`/admin/categories${parentId ? `?parent_id=${parentId}` : ''}`)
}
const onError = () => {
setUpdateInfo({})
confirmUpdateModalHandlers.close()
}
return (
<>
{/* Handle Update Category Response */}
{(isSuccess_update || isError_update) && (
<HandleResponse
isError={isError_update}
isSuccess={isSuccess_update}
error={error_update?.data?.message}
message={data_update?.message}
onSuccess={onSuccess}
onError={onError}
/>
)}
<ConfirmUpdateModal
title="分类"
isLoading={isLoading_update}
isShow={isShowConfirmUpdateModal}
onClose={confirmUpdateModalHandlers.close}
onConfirm={onConfirm}
onCancel={onCancel}
/>
<main>
<PageContainer title="编辑分类">
{isLoading_get ? (
<div className="px-3 py-20">
<BigLoading />
</div>
) : selectedCategory ? (
<CategoryForm
mode="edit"
isLoading={isLoading_update}
selectedCategory={selectedCategory}
updateHandler={updateHandler}
/>
) : null}
</PageContainer>
</main>
</>
)
}

View File

@ -0,0 +1,129 @@
'use client'
import { BigLoading, PageContainer } from '@/components'
import { useTitle, useUrlQuery } from '@/hooks'
import { useGetCategoriesQuery } from '@/store/services'
import Link from 'next/link'
export default function CategoriesPage() {
useTitle('分类管理')
const query = useUrlQuery()
const parentId = query.parent_id
const parentLvl = query.parent_lvl
//? Get Categories Data
const { childCategories, isLoading } = useGetCategoriesQuery(undefined, {
selectFromResult: ({ data, isLoading }) => {
return {
childCategories: data?.data?.categories?.filter(category => category.parent === parentId),
isLoading,
}
},
})
//? Render(s)
if (isLoading)
return (
<div className="px-3 py-20">
<BigLoading />
</div>
)
return (
<PageContainer title="分类管理">
<section className="p-3">
<div className="space-y-8 text-white">
<div className="flex justify-between">
{childCategories && childCategories[0]?.level !== 0 ? (
<Link
href={`categories/create${parentId ? `?parent_id=${parentId}` : ''}&${
parentLvl ? `parent_lvl=${parentLvl}` : ''
}`}
className="flex items-center px-3 py-2 text-red-600 border-2 border-red-600 rounded-lg max-w-max gap-x-3"
>
添加新文件夹
</Link>
) : (
<div />
)}
<Link
href="/admin/categories/tree"
className="flex items-center px-3 py-2 text-red-600 border-2 border-red-600 rounded-lg max-w-max gap-x-3"
>
图表展示
</Link>
</div>
<div className=" overflow-x-auto mt-7 ">
<table className="w-full whitespace-nowrap">
<thead className="h-9 bg-emerald-50">
<tr className="text-emerald-500">
<th className="px-2 border-gray-100 border-x-2">分类名称</th>
<th className="border-gray-100 border-x-2">操作</th>
</tr>
</thead>
<tbody className="text-gray-600">
{childCategories && childCategories.length > 0 ? (
childCategories?.map(category => (
<tr
className="text-xs text-center transition-colors border-b border-gray-100 md:text-sm hover:bg-gray-50/50 "
key={category._id}
>
<td className="w-2/4 px-2 py-4">{category.name}</td>
<td className=" gap-3 px-2 py-4">
{category.level !== 3 && (
<Link
href={`/admin/categories?parent_id=${category._id}&parent_lvl=${category.level}`}
className="bg-green-50 text-green-500 rounded-sm py-1 px-1.5 max-w-min"
>
子类
</Link>
)}
<Link
href={`/admin/categories/edit/${category._id}?${
parentId ? `parent_id=${parentId}` : ''
}&${parentLvl ? `parent_lvl=${parentLvl}` : ''}`}
className="bg-amber-50 text-amber-500 rounded-sm py-1 px-1.5 max-w-min"
>
编辑
</Link>
{category.level === 2 && (
<Link
href={`/admin/details/${category._id}?category_name=${category.name}`}
className="bg-blue-50 text-blue-500 rounded-sm py-1 px-1.5 max-w-min"
>
规格和特点
</Link>
)}
{category.level < 2 && (
<>
<Link
href={`/admin/sliders?category_id=${category._id}&category_name=${category.name}`}
className="bg-fuchsia-50 text-fuchsia-500 rounded-sm py-1 px-1.5 max-w-min"
>
滑块
</Link>
<Link
href={`/admin/banners?category_id=${category._id}&category_name=${category.name}`}
className="bg-rose-50 text-rose-500 rounded-sm py-1 px-1.5 max-w-min"
>
横幅
</Link>
</>
)}
</td>
</tr>
))
) : (
<tr>
<td>
<p className="py-4 text-sm text-center text-red-700">还没有分类</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
</PageContainer>
)
}

View File

@ -0,0 +1,79 @@
'use client'
import { BigLoading, PageContainer } from 'components'
import { useGetCategoriesQuery } from '@/store/services'
import { useTitle } from '@/hooks'
export default function CategoriesTreePage() {
useTitle('分类图表')
//? Get Categories Data
const { categoriesList, isLoading } = useGetCategoriesQuery(undefined, {
selectFromResult: ({ data, isLoading }) => ({
categoriesList: data?.data?.categoriesList,
isLoading,
}),
})
//? Render(s)
return (
<main>
{isLoading ? (
<div className="px-3 py-20">
<BigLoading />
</div>
) : (
<PageContainer title="分类图表">
<section className="p-3">
<div className="space-y-8 text-white">
<div className="flex text-gray-600 gap-x-3">
<p className="flex items-center text-sm gap-x-1">
<span className="inline-block w-6 h-6 bg-red-500 rounded-md" />
一级
</p>
<p className="flex items-center text-sm gap-x-1">
<span className="inline-block w-6 h-6 bg-green-500 rounded-md" />
二级
</p>
<p className="flex items-center text-sm gap-x-1">
<span className="inline-block w-6 h-6 bg-blue-500 rounded-md" />
三级
</p>
</div>
<ul className="space-y-8">
{categoriesList &&
categoriesList.children?.map(mainCategory => (
<li
key={mainCategory._id}
className="p-2 border border-gray-100 rounded-md shadow"
>
<div className="p-2 text-center bg-red-500 rounded">{mainCategory.name}</div>
<ul className="flex flex-wrap gap-x-4">
{mainCategory.children.map(parentCategory => (
<li key={parentCategory._id} className="flex-1">
<div className="p-2 mt-2 text-center bg-green-500 rounded">
{parentCategory.name}
</div>
<ul className="flex flex-wrap gap-x-4">
{parentCategory.children.map(childCategory => (
<li key={childCategory._id} className="flex-1">
<div className="flex-1 p-2 mt-2 text-center bg-blue-500 rounded">
{childCategory.name}
</div>
</li>
))}
</ul>
</li>
))}
</ul>
</li>
))}
</ul>
</div>
</section>
</PageContainer>
)}
</main>
)
}

View File

@ -0,0 +1,380 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { showAlert } from 'store'
import {
useCreateDetailsMutation,
useDeleteDetailsMutation,
useGetDetailsQuery,
useUpdateDetailsMutation,
} from '@/store/services'
import {
BigLoading,
Button,
ConfirmDeleteModal,
ConfirmUpdateModal,
DetailsList,
HandleResponse,
PageContainer,
} from 'components'
import { Tab } from '@headlessui/react'
import { useAppDispatch, useDisclosure } from 'hooks'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useTitle, useUrlQuery } from '@/hooks'
const tabListNames = [
{ id: 0, name: '选择类型' },
{ id: 1, name: '属性' },
{ id: 2, name: '规格' },
]
const DetailsContentPage = ({ params: { id } }) => {
//? Assets
const { back } = useRouter()
const query = useUrlQuery()
const dispatch = useAppDispatch()
const categoryId = id
const categoryName = query.category_name
const initialUpdataInfo = {}
//? Modals
const [isShowConfirmDeleteModal, confirmDeleteModalHandlers] = useDisclosure()
const [isShowConfirmUpdateModal, confirmUpdateModalHandlers] = useDisclosure()
//? States
const [updateInfo, setUpdateInfo] = useState(initialUpdataInfo)
const [mode, setMode] = useState('create')
//? Queries
//* Get Details
const { data: details, isLoading: isLoadingGet } = useGetDetailsQuery({
id: categoryId,
})
//* Update Details
const [
updateDetails,
{
data: dataUpdate,
isSuccess: isSuccessUpdate,
isError: isErrorUpdate,
error: errorUpdate,
isLoading: isLoadingUpdate,
},
] = useUpdateDetailsMutation()
//* Create Details
const [
createDetails,
{
data: dataCreate,
isSuccess: isSuccessCreate,
isError: isErrorCreate,
isLoading: isLoadingCreate,
error: errorCreate,
},
] = useCreateDetailsMutation()
//* Delete Details
const [
deleteDetails,
{
isSuccess: isSuccessDelete,
isError: isErrorDelete,
error: errorDelete,
data: dataDelete,
isLoading: isLoadingDelete,
},
] = useDeleteDetailsMutation()
//? Hook Form
const { handleSubmit, register, reset, control } = useForm({
defaultValues: {
optionsType: 'none',
info: [],
specification: [],
},
})
//? Re-Renders
useEffect(() => {
if (details?.data) {
setMode('edit')
reset({
optionsType: details?.data?.optionsType,
info: details?.data?.info,
specification: details?.data?.specification,
})
}
}, [details])
//? Handlers
//* Create
const createHandler = async ({ info, specification, optionsType }) => {
if (info.length !== 0 && specification.length !== 0) {
await createDetails({
body: {
category_id: categoryId,
info,
specification,
optionsType,
},
})
} else {
dispatch(
showAlert({
status: 'error',
title: '请输入详细信息和属性',
})
)
}
}
//* Update
const updateHandler = ({ info, specification, optionsType }) => {
setUpdateInfo(prev => ({
...prev,
...details?.data,
info,
specification,
optionsType,
}))
confirmUpdateModalHandlers.open()
}
const onConfirmUpdate = () => {
updateDetails({
id: details?.data?._id,
body: updateInfo,
})
}
const onCancelUpdate = () => {
setUpdateInfo(initialUpdataInfo)
confirmUpdateModalHandlers.close()
}
const onSuccessUpdate = () => {
setUpdateInfo(initialUpdataInfo)
confirmUpdateModalHandlers.close()
}
const onErrorUpdate = () => {
setUpdateInfo(initialUpdataInfo)
confirmUpdateModalHandlers.close()
}
//* Delete
const deleteHandler = () => confirmDeleteModalHandlers.open()
const onConfirmDelete = () => deleteDetails({ id: details?.data?._id })
const onCancelDelete = () => confirmDeleteModalHandlers.close()
const onSuccessDelete = () => {
confirmDeleteModalHandlers.close()
reset({
optionsType: 'none',
info: [],
specification: [],
})
back()
}
const onErrorDelete = () => confirmDeleteModalHandlers.close()
useTitle(`品类规格及特点 - ${categoryName ? categoryName : ''}`)
//? Render(s)
return (
<>
<ConfirmDeleteModal
title={`${categoryName}-分类规格`}
isLoading={isLoadingDelete}
isShow={isShowConfirmDeleteModal}
onClose={confirmDeleteModalHandlers.close}
onCancel={onCancelDelete}
onConfirm={onConfirmDelete}
/>
{/* Handle Delete Response */}
{(isSuccessDelete || isErrorDelete) && (
<HandleResponse
isError={isErrorDelete}
isSuccess={isSuccessDelete}
error={errorDelete?.data?.message}
message={dataDelete?.message}
onSuccess={onSuccessDelete}
onError={onErrorDelete}
/>
)}
<ConfirmUpdateModal
title={`${categoryName}-分类规格`}
isLoading={isLoadingUpdate}
isShow={isShowConfirmUpdateModal}
onClose={confirmUpdateModalHandlers.close}
onCancel={onCancelUpdate}
onConfirm={onConfirmUpdate}
/>
{/* Handle Update Response */}
{(isSuccessUpdate || isErrorUpdate) && (
<HandleResponse
isError={isErrorUpdate}
isSuccess={isSuccessUpdate}
error={errorUpdate?.data?.message}
message={dataUpdate?.message}
onSuccess={onSuccessUpdate}
onError={onErrorUpdate}
/>
)}
{/* Handle Create Details Response */}
{(isSuccessCreate || isErrorCreate) && (
<HandleResponse
isError={isErrorCreate}
isSuccess={isSuccessCreate}
error={errorCreate?.data?.message}
message={dataCreate?.message}
/>
)}
<main>
{isLoadingGet ? (
<div className="px-3 py-20">
<BigLoading />
</div>
) : (
<PageContainer title={`品类规格及特点 - ${categoryName ? categoryName : ''}`}>
<form
onSubmit={
mode === 'create' ? handleSubmit(createHandler) : handleSubmit(updateHandler)
}
className="p-3 space-y-6"
>
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-slate-200 p-1">
{tabListNames.map(item => (
<Tab
key={item.id}
className={({ selected }) =>
`tab
${
selected
? 'bg-white shadow'
: 'text-blue-400 hover:bg-white/[0.12] hover:text-blue-600'
}
`
}
>
{item.name}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<div className="space-y-3">
<p className="mb-2">选择类型</p>
<div className="flex items-center gap-x-1">
<input
type="radio"
id="none"
value="none"
className="mr-1"
{...register('optionsType')}
/>
<label htmlFor="none">默认</label>
</div>
<div className="flex items-center gap-x-1">
<input
type="radio"
id="colors"
value="colors"
className="mr-1"
{...register('optionsType')}
/>
<label htmlFor="colors">根据颜色</label>
</div>
<div className="flex items-center gap-x-1">
<input
type="radio"
id="sizes"
value="sizes"
className="mr-1"
{...register('optionsType')}
/>
<label htmlFor="sizes">根据尺寸</label>
</div>
</div>
</Tab.Panel>
<Tab.Panel>
<DetailsList
name="info"
control={control}
register={register}
categoryName={categoryName}
/>
</Tab.Panel>
<Tab.Panel>
<DetailsList
name="specification"
control={control}
register={register}
categoryName={categoryName}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
<div className="flex justify-center gap-x-4">
{mode === 'edit' ? (
<>
<Button
className="bg-amber-500 "
isRounded={true}
type="submit"
isLoading={isLoadingUpdate}
>
更新分类规格
</Button>
<Button
className="rounded-3xl"
isLoading={isLoadingDelete}
onClick={deleteHandler}
>
删除分类规格
</Button>
</>
) : (
<Button
className="bg-green-500 "
isRounded={true}
type="submit"
isLoading={isLoadingCreate}
>
建立分类规格
</Button>
)}
</div>
</form>
</PageContainer>
)}
</main>
</>
)
}
export default DetailsContentPage

View File

@ -0,0 +1,75 @@
'use client'
import Link from 'next/link'
import { BigLoading, PageContainer } from 'components'
import { useGetCategoriesQuery } from '@/store/services'
import { useTitle } from '@/hooks'
import moment from 'moment-jalaali'
const DetailsPage = () => {
useTitle('分类规格')
//? Get Categories
const { categories, isLoading } = useGetCategoriesQuery(undefined, {
selectFromResult: ({ data, isLoading }) => ({
categories: data?.data?.categories.filter(category => category.level === 2),
isLoading,
}),
})
//? Render(s)
if (isLoading)
return (
<div className="px-3 py-20">
<BigLoading />
</div>
)
return (
<main>
<PageContainer title="分类规格">
<section className="p-3 mx-auto mb-10 space-y-8">
<div className="mx-3 overflow-x-auto mt-7 lg:mx-5 xl:mx-10">
<table className="w-full whitespace-nowrap">
<thead className="h-9 bg-emerald-50">
<tr className="text-emerald-500">
<th className="px-2 border-gray-100 border-x-2">名称</th>
<th className="px-2 border-gray-100 border-x-2">创建时间</th>
<th className="px-2 border-gray-100 border-x-2">更新时间</th>
<th className="border-gray-100 border-x-2">操作</th>
</tr>
</thead>
<tbody className="text-gray-600">
{categories &&
categories.map(category => (
<tr
className="text-xs text-center transition-colors border-b border-gray-100 md:text-sm hover:bg-gray-50"
key={category._id}
>
<td className="w-1/4 px-2 py-4">{category.name}</td>
<td className="w-1/4 px-2 py-4">
{moment(category.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</td>
<td className="w-1/4 px-2 py-4">
{moment(category.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</td>
<td className="px-2 py-4">
<Link
href={`/admin/details/${category._id}?category_name=${category.name}`}
className="bg-blue-50 text-blue-500 rounded-sm py-1 px-1.5 mx-1.5 inline-block"
>
编辑规格
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</PageContainer>
</main>
)
}
export default DetailsPage

View File

@ -0,0 +1,29 @@
'use client' // Error components must be Client Components
import { Button } from '@/components'
import { useEffect } from 'react'
export default function Error({ error, reset }) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
return (
<>
<div className="py-20 mx-auto space-y-3 text-center w-fit">
<h5 className="text-xl">{error.name}</h5>
<p className="text-lg text-red-500">{error.message}</p>
<Button
className="mx-auto"
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
重试
</Button>
</div>
</>
)
}

View File

@ -0,0 +1,11 @@
'use client'
import { DashboardLayout } from 'components'
export default function Layout({ children }) {
return (
<>
<DashboardLayout>{children}</DashboardLayout>
</>
)
}

View File

@ -0,0 +1,19 @@
import { ArrowLink, ResponsiveImage, DashboardLayout } from '@/components'
export default function NotFound() {
//? Render(s)
return (
<DashboardLayout>
<main className="flex flex-col items-center justify-center py-8 gap-y-6 xl:mt-28">
<p className="text-base font-semibold text-black">404 Not Found!</p>
<ArrowLink path="/admin">返回管理后台</ArrowLink>
<ResponsiveImage
dimensions="w-full max-w-lg h-72"
src="/icons/page-not-found.png"
layout="fill"
alt="404"
/>
</main>
</DashboardLayout>
)
}

View File

@ -0,0 +1,37 @@
'use client'
import { useGetSingleOrderQuery } from '@/store/services'
import { BigLoading, DashboardLayout, OrderCard, PageContainer } from 'components'
import { useTitle, useUrlQuery } from '@/hooks'
const SingleOrder = ({ params }) => {
useTitle('订单详情')
//? Assets
const query = useUrlQuery()
//? Get Order Data
const { data, isLoading } = useGetSingleOrderQuery({
id: params.id,
})
//? Render(s)
return (
<main>
<PageContainer title="订单详情">
{isLoading ? (
<div className="px-3 py-20">
<BigLoading />
</div>
) : data ? (
<section className="max-w-5xl px-3 py-3 mx-auto lg:px-8">
<OrderCard singleOrder order={data?.data} />
</section>
) : null}
</PageContainer>
</main>
)
}
export default SingleOrder

View File

@ -0,0 +1,65 @@
'use client'
import { useGetOrdersListQuery } from '@/store/services'
import {
Pagination,
ShowWrapper,
EmptyOrdersList,
PageContainer,
OrdersTable,
TableSkeleton,
} from 'components'
import { useChangeRoute } from 'hooks'
import { useTitle, useUrlQuery } from '@/hooks'
const OrdersHome = () => {
useTitle('订单管理')
//? Assets
const query = useUrlQuery()
const page = query.page ? +query.page : 1
const changeRoute = useChangeRoute()
//? Get Orders Query
const { data, isSuccess, isFetching, error, isError, refetch } = useGetOrdersListQuery({
page,
pageSize: 10,
})
//? Render(s)
return (
<main id="_adminOrders">
<PageContainer title="订单管理">
<section className="p-3 md:px-3 xl:px-8 2xl:px-10" id="orders">
<ShowWrapper
error={error}
isError={isError}
refetch={refetch}
isFetching={isFetching}
isSuccess={isSuccess}
dataLength={data?.data?.ordersLength ?? 0}
emptyComponent={<EmptyOrdersList />}
loadingComponent={<TableSkeleton />}
>
{data && <OrdersTable orders={data?.data?.orders} />}
</ShowWrapper>
{data && data?.data?.ordersLength > 10 && (
<div className="py-4 mx-auto lg:max-w-5xl">
<Pagination
pagination={data?.data?.pagination}
changeRoute={changeRoute}
section="_adminOrders"
/>
</div>
)}
</section>
</PageContainer>
</main>
)
}
export default OrdersHome

View File

@ -0,0 +1,31 @@
'use client'
import { DashboardAside } from '@/components'
import { useTitle } from '@/hooks'
import { siteTitle } from '@/utils'
import Image from 'next/image'
const AdminPage = () => {
useTitle(`${siteTitle}-管理中心`)
return (
<>
<div className="lg:hidden">
<DashboardAside />
</div>
<section className="hidden py-20 lg:block">
<Image
src="/icons/chart.png"
alt="图表"
width={208}
height={208}
className="mx-auto mb-8"
/>
<p className="text-center">情况分析</p>
<span className="block my-3 text-base text-center text-amber-500">(开发中)</span>
</section>
</>
)
}
export default AdminPage

View File

@ -0,0 +1,50 @@
'use client'
import { useRouter } from 'next/navigation'
import { HandleResponse, PageContainer, ProductsForm } from 'components'
import { useCreateProductMutation } from '@/store/services'
import { useTitle } from '@/hooks'
const CreateProductPage = () => {
useTitle('商品新增')
//? Assets
const { push } = useRouter()
//? Queries
//* Create Product
const [createProduct, { data, isSuccess, isLoading, isError, error }] = useCreateProductMutation()
//? Handlers
const createHandler = data => {
console.log(data)
createProduct({ body: data })
}
const onSuccess = () => {
push('/admin/products')
}
return (
<>
{(isSuccess || isError) && (
<HandleResponse
isError={isError}
isSuccess={isSuccess}
error={error?.data?.message}
message={data?.message}
onSuccess={onSuccess}
/>
)}
<main>
<PageContainer title="商品新增">
<ProductsForm mode="create" isLoadingCreate={isLoading} createHandler={createHandler} />
</PageContainer>
</main>
</>
)
}
export default CreateProductPage

View File

@ -0,0 +1,122 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import {
BigLoading,
ConfirmUpdateModal,
HandleResponse,
PageContainer,
ProductsForm,
} from 'components'
import { useDisclosure } from 'hooks'
import { SubmitHandler } from 'react-hook-form'
import { useGetSingleProductQuery, useUpdateProductMutation } from '@/store/services'
import { useTitle } from '@/hooks'
const EditProductPage = ({ params: { id } }) => {
useTitle('编辑商品')
//? Assets
const { back } = useRouter()
const initialUpdataInfo = {}
//? Modals
const [isShowConfirmUpdateModal, confirmUpdateModalHandlers] = useDisclosure()
//? States
const [updateInfo, setUpdateInfo] = useState(initialUpdataInfo)
//? Queries
//* Get Product
const { data: selectedProduct, isLoading: isLoadingGetSelectedProduct } =
useGetSingleProductQuery({ id })
//* Update Product
const [
updateProduct,
{
data: dataUpdate,
isSuccess: isSuccessUpdate,
isError: isErrorUpdate,
error: errorUpdate,
isLoading: isLoadingUpdate,
},
] = useUpdateProductMutation()
//? Handlers
const updateHandler = data => {
setUpdateInfo(prev => ({ ...prev, ...selectedProduct.data, ...data }))
confirmUpdateModalHandlers.open()
}
const onConfirmUpdate = () => {
updateProduct({
id,
body: updateInfo,
})
}
const onCancelUpdate = () => {
setUpdateInfo(initialUpdataInfo)
confirmUpdateModalHandlers.close()
}
const onSuccessUpdate = () => {
setUpdateInfo(initialUpdataInfo)
confirmUpdateModalHandlers.close()
back()
}
const onErrorUpdate = () => {
setUpdateInfo(initialUpdataInfo)
confirmUpdateModalHandlers.close()
}
return (
<>
<ConfirmUpdateModal
title="商品"
isLoading={isLoadingUpdate}
isShow={isShowConfirmUpdateModal}
onClose={confirmUpdateModalHandlers.close}
onCancel={onCancelUpdate}
onConfirm={onConfirmUpdate}
/>
{(isSuccessUpdate || isErrorUpdate) && (
<HandleResponse
isError={isErrorUpdate}
isSuccess={isSuccessUpdate}
error={errorUpdate?.data?.message}
message={dataUpdate?.message}
onSuccess={onSuccessUpdate}
onError={onErrorUpdate}
/>
)}
<main>
<PageContainer title="编辑商品">
{isLoadingGetSelectedProduct ? (
<div className="px-3 py-20">
<BigLoading />
</div>
) : selectedProduct.data ? (
<ProductsForm
mode="edit"
isLoadingUpdate={isLoadingUpdate}
updateHandler={updateHandler}
selectedProduct={selectedProduct.data}
/>
) : null}
</PageContainer>
</main>
</>
)
}
export default EditProductPage

View File

@ -0,0 +1,290 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import {
useDeleteProductMutation,
useGetCategoriesQuery,
useGetProductsQuery,
} from '@/store/services'
import {
ConfirmDeleteModal,
DeleteIconBtn,
EditIconBtn,
HandleResponse,
Icons,
PageContainer,
Pagination,
SelectCategories,
ShowWrapper,
TableSkeleton,
} from 'components'
import { useDisclosure, useChangeRoute } from 'hooks'
import { useTitle, useUrlQuery } from '@/hooks'
const Products = () => {
useTitle('商品管理')
//? Assets
const { push } = useRouter()
const query = useUrlQuery()
const page = query.page ? +query.page : 1
const category = query.category ?? ''
const changeRoute = useChangeRoute()
const initialSelectedCategories = {
levelOne: {},
levelTwo: {},
levelThree: {},
}
//? Get Categories Query
const { categories } = useGetCategoriesQuery(undefined, {
selectFromResult: ({ data }) => ({
categories: data?.data?.categories,
}),
})
//? Modals
const [isShowConfirmDeleteModal, confirmDeleteModalHandlers] = useDisclosure()
//? State
const [deleteInfo, setDeleteInfo] = useState({
id: '',
})
const [search, setSearch] = useState('')
const [selectedCategories, setSelectedCategories] = useState(initialSelectedCategories)
//? Querirs
//* Get Products Data
const { data, isFetching, error, isError, refetch, isSuccess } = useGetProductsQuery({
page,
category,
search: query?.search,
})
//* Delete Product
const [
deleteProduct,
{
isSuccess: isSuccessDelete,
isError: isErrorDelete,
error: errorDelete,
data: dataDelete,
isLoading: isLoadingDelete,
},
] = useDeleteProductMutation()
//? Handlers
const handleSearchChange = e => setSearch(e.target.value)
const handleSubmit = e => {
e.preventDefault()
const queryParams = {
page: 1,
}
if (selectedCategories?.levelThree?._id) {
queryParams.category = selectedCategories.levelThree.slug
queryParams.levelOne = selectedCategories?.levelOne?._id
queryParams.levelTwo = selectedCategories?.levelTwo?._id
queryParams.levelThree = selectedCategories.levelThree._id
} else if (selectedCategories?.levelTwo?._id) {
queryParams.category = selectedCategories?.levelTwo.slug
queryParams.levelOne = selectedCategories?.levelOne?._id
queryParams.levelTwo = selectedCategories?.levelTwo._id
} else if (selectedCategories?.levelOne?._id) {
queryParams.category = selectedCategories?.levelOne.slug
queryParams.levelOne = selectedCategories?.levelOne._id
}
if (search.trim()) {
queryParams.search = search
}
changeRoute(queryParams)
}
const handleRemoveSearch = () => {
setSearch('')
setSelectedCategories(initialSelectedCategories)
refetch()
push('/admin/products')
}
const findCategory = id => categories?.find(cat => cat._id === id)
//* Delete Handlers
const handleDelete = id => {
setDeleteInfo({ id })
confirmDeleteModalHandlers.open()
}
const onCancel = () => {
setDeleteInfo({ id: '' })
confirmDeleteModalHandlers.close()
}
const onConfirm = () => {
deleteProduct({ id: deleteInfo.id })
}
const onSuccess = () => {
confirmDeleteModalHandlers.close()
setDeleteInfo({ id: '' })
}
const onError = () => {
confirmDeleteModalHandlers.close()
setDeleteInfo({ id: '' })
}
//? Re-Render
useEffect(() => {
if (categories) {
if (query?.levelThree)
setSelectedCategories({
levelOne: findCategory(query.levelOne),
levelThree: findCategory(query.levelThree),
levelTwo: findCategory(query.levelTwo),
})
else if (query?.levelTwo)
setSelectedCategories({
...selectedCategories,
levelOne: findCategory(query.levelOne),
levelTwo: findCategory(query.levelTwo),
})
else if (query?.levelOne)
setSelectedCategories({
...selectedCategories,
levelOne: findCategory(query.levelOne),
})
}
}, [categories])
useEffect(() => {
if (query?.search) setSearch(query.search)
}, [query?.search])
//? Render(s)
return (
<>
<ConfirmDeleteModal
title="该商品"
isLoading={isLoadingDelete}
isShow={isShowConfirmDeleteModal}
onClose={confirmDeleteModalHandlers.close}
onCancel={onCancel}
onConfirm={onConfirm}
/>
{/* Handle Delete Response */}
{(isSuccessDelete || isErrorDelete) && (
<HandleResponse
isError={isErrorDelete}
isSuccess={isSuccessDelete}
error={errorDelete?.data?.message}
message={dataDelete?.message}
onSuccess={onSuccess}
onError={onError}
/>
)}
<main>
<PageContainer title="商品管理">
<section className="p-3 space-y-7" id="_adminProducts">
<form className="max-w-4xl mx-auto space-y-5" onSubmit={handleSubmit}>
<SelectCategories
setSelectedCategories={setSelectedCategories}
selectedCategories={selectedCategories}
/>
<div className="flex flex-row-reverse rounded-md gap-x-2 ">
<button
type="button"
className="p-2 text-white border flex-center gap-x-2 min-w-max"
onClick={handleRemoveSearch}
>
<span>重制筛选</span>
<Icons.Close className="icon" />
</button>
<input
type="text"
placeholder="商品名称..."
className="flex-grow p-2 text-left input"
value={search}
onChange={handleSearchChange}
/>
<button type="submit" className="p-2 border flex-center gap-x-2 min-w-max">
<span>过滤筛选</span>
<Icons.Search className="icon" />
</button>
</div>
</form>
<ShowWrapper
error={error}
isError={isError}
refetch={refetch}
isFetching={isFetching}
isSuccess={isSuccess}
dataLength={data?.data ? data?.data?.productsLength : 0}
loadingComponent={<TableSkeleton count={10} />}
>
<div className="overflow-x mt-7">
<table className="w-full overflow-scroll table-auto">
<thead className="h-9 bg-emerald-50">
<tr className="text-emerald-500">
<th className="border-gray-50 border-x-2">ID</th>
<th className="border-gray-100 border-x-2">名称</th>
<th className="border-gray-100 border-x-2">价格</th>
<th className="border-gray-100 border-x-2">销量</th>
<th className="border-gray-100 border-x-2">库存</th>
<th className="border-r-2 border-gray-100">操作</th>
</tr>
</thead>
<tbody className="text-gray-600">
{data?.data?.products.length > 0 &&
data?.data?.products.map(item => (
<tr
className="text-xs text-center transition-colors border-b border-gray-100 md:text-sm hover:bg-gray-50"
key={item._id}
>
<td className="px-2 py-4">{item._id}</td>
<td className="px-2 py-4">{item.title}</td>
<td className="px-2 py-4">{item.price}</td>
<td className="px-2 py-4">{item.sold}</td>
<td className="px-2 py-4">{item.inStock}</td>
<td className="px-2 py-4">
<DeleteIconBtn onClick={() => handleDelete(item._id)} />
<Link href={`/admin/products/edit/${item._id}`}>
<EditIconBtn />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</ShowWrapper>
{data && data?.data?.productsLength > 10 && (
<Pagination
pagination={data?.data?.pagination}
changeRoute={changeRoute}
section="_adminProducts"
/>
)}
</section>
</PageContainer>
</main>
</>
)
}
export default Products

View File

@ -0,0 +1,33 @@
'use client'
import { useTitle } from '@/hooks'
import { useGetSingleReviewQuery } from '@/store/services'
import { BigLoading, PageContainer, ReveiwCard } from 'components'
const SingleCommentPage = ({ params: { id } }) => {
useTitle('评价详情')
//? Get Single Review Data
const { data, isLoading } = useGetSingleReviewQuery({
id,
})
//? Render(s)
return (
<main>
<PageContainer title="评价详情">
{isLoading ? (
<div className="px-3 py-20">
<BigLoading />
</div>
) : data ? (
<section className="px-3 py-3 mx-auto lg:px-8">
<ReveiwCard singleComment item={data.data} />
</section>
) : null}
</PageContainer>
</main>
)
}
export default SingleCommentPage

View File

@ -0,0 +1,60 @@
'use client'
import { useGetReviewsListQuery } from '@/store/services'
import {
Pagination,
ShowWrapper,
EmptyCommentsList,
PageContainer,
ReviewsTable,
TableSkeleton,
} from 'components'
import { useChangeRoute } from 'hooks'
import { useTitle, useUrlQuery } from '@/hooks'
const ReviewsPage = () => {
useTitle('评价管理')
//? Assets
const query = useUrlQuery()
const page = query.page ? +query.page : 1
const changeRoute = useChangeRoute()
//? Get Review Data
const { data, isError, error, isFetching, refetch, isSuccess } = useGetReviewsListQuery({
page,
})
//? Render
return (
<main id="_adminReviews">
<PageContainer title="评价管理">
<ShowWrapper
error={error}
isError={isError}
refetch={refetch}
isFetching={isFetching}
isSuccess={isSuccess}
dataLength={data?.data?.reviewsLength ?? 0}
emptyComponent={<EmptyCommentsList />}
loadingComponent={<TableSkeleton />}
>
{data && data?.data && <ReviewsTable reviews={data?.data?.reviews} />}
</ShowWrapper>
{data && data?.data && data?.data?.reviewsLength > 10 && (
<div className="py-4 mx-auto lg:max-w-5xl">
<Pagination
pagination={data?.data?.pagination}
changeRoute={changeRoute}
section="_adminReviews"
/>
</div>
)}
</PageContainer>
</main>
)
}
export default ReviewsPage

View File

@ -0,0 +1,55 @@
'use client'
import { useRouter } from 'next/navigation'
import { HandleResponse, PageContainer, SliderForm } from 'components'
import { SubmitHandler } from 'react-hook-form'
import { useCreateSliderMutation } from '@/store/services'
import { useTitle, useUrlQuery } from '@/hooks'
const CreateSliderPage = () => {
//? Assets
const { back } = useRouter()
const query = useUrlQuery()
const categoryName = query?.category_name
const categoryId = query?.category_id
//? Queries
//* Create Slider
const [createSlider, { data, isSuccess, isLoading, error, isError }] = useCreateSliderMutation()
//? Handlers
const createHandler = data => {
const { image, isPublic, title, uri } = data
createSlider({
body: { category_id: categoryId, image, isPublic, title, uri },
})
}
const onSuccess = () => back()
useTitle('新增类别滑块' + ' ' + categoryName)
return (
<>
{(isSuccess || isError) && (
<HandleResponse
isError={isError}
isSuccess={isSuccess}
error={error?.data?.message}
message={data?.message}
onSuccess={onSuccess}
/>
)}
<main>
<PageContainer title={'新增类别滑块' + ' ' + categoryName}>
<SliderForm mode="create" isLoadingCreate={isLoading} createHandler={createHandler} />
</PageContainer>
</main>
</>
)
}
export default CreateSliderPage

View File

@ -0,0 +1,183 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import {
BigLoading,
ConfirmDeleteModal,
ConfirmUpdateModal,
HandleResponse,
PageContainer,
SliderForm,
} from 'components'
import { useDisclosure } from 'hooks'
import { SubmitHandler } from 'react-hook-form'
import {
useDeleteSliderMutation,
useGetSingleSliderQuery,
useUpdateSliderMutation,
} from '@/store/services'
import { useTitle, useUrlQuery } from '@/hooks'
const EditSliderPage = ({ params: { id: sliderId } }) => {
//? Assets
const { back } = useRouter()
const query = useUrlQuery()
const sliderName = query?.slider_name
const initialUpdataInfo = {}
//? States
const [updateInfo, setUpdateInfo] = useState(initialUpdataInfo)
//? Modals
const [isShowConfirmDeleteModal, confirmDeleteModalHandlers] = useDisclosure()
const [isShowConfirmUpdateModal, confirmUpdateModalHandlers] = useDisclosure()
//? Queries
//* Get Slider
const { data: selectedSlider, isLoading: isLoadingGetSelectedSlider } = useGetSingleSliderQuery({
id: sliderId,
})
//* Update Slider
const [
updateSlider,
{
data: dataUpdate,
isSuccess: isSuccessUpdate,
isError: isErrorUpdate,
error: errorUpdate,
isLoading: isLoadingUpdate,
},
] = useUpdateSliderMutation()
//* Delete Slider
const [
deleteSlider,
{
isSuccess: isSuccessDelete,
isError: isErrorDelete,
error: errorDelete,
data: dataDelete,
isLoading: isLoadingDelete,
},
] = useDeleteSliderMutation()
//? Handlers
//* Update
const updateHandler = data => {
setUpdateInfo(prev => ({ ...prev, ...selectedSlider.data, ...data }))
confirmUpdateModalHandlers.open()
}
const onConfirmUpdate = () => {
updateSlider({
id: sliderId,
body: updateInfo,
})
}
const onCancelUpdate = () => {
setUpdateInfo(initialUpdataInfo)
confirmUpdateModalHandlers.close()
}
const onSuccessUpdate = () => {
setUpdateInfo(initialUpdataInfo)
confirmUpdateModalHandlers.close()
console.log('isSuccessUpdate', isSuccessUpdate)
console.log('isErrorUpdate', isErrorUpdate)
back()
}
const onErrorUpdate = () => {
setUpdateInfo(initialUpdataInfo)
confirmUpdateModalHandlers.close()
}
//* Delete
const deleteHandler = () => confirmDeleteModalHandlers.open()
const onConfirmDelete = () => deleteSlider({ id: sliderId })
const onCancelDelete = () => confirmDeleteModalHandlers.close()
const onSuccessDelete = () => {
confirmDeleteModalHandlers.close()
back()
}
const onErrorDelete = () => confirmDeleteModalHandlers.close()
useTitle('编辑滑块' + ' ' + sliderName)
return (
<>
<ConfirmDeleteModal
title="滑块"
isLoading={isLoadingDelete}
isShow={isShowConfirmDeleteModal}
onClose={confirmDeleteModalHandlers.close}
onCancel={onCancelDelete}
onConfirm={onConfirmDelete}
/>
{(isSuccessDelete || isErrorDelete) && (
<HandleResponse
isError={isErrorDelete}
isSuccess={isSuccessDelete}
error={errorDelete?.data?.message}
message={dataDelete?.message}
onSuccess={onSuccessDelete}
onError={onErrorDelete}
/>
)}
<ConfirmUpdateModal
title="滑块"
isLoading={isLoadingUpdate}
isShow={isShowConfirmUpdateModal}
onClose={confirmUpdateModalHandlers.close}
onCancel={onCancelUpdate}
onConfirm={onConfirmUpdate}
/>
{(isSuccessUpdate || isErrorUpdate) && (
<HandleResponse
isError={isErrorUpdate}
isSuccess={isSuccessUpdate}
error={errorUpdate?.data?.message}
message={dataUpdate?.message}
onSuccess={onSuccessUpdate}
onError={onErrorUpdate}
/>
)}
<main>
<PageContainer title={'编辑滑块' + ' ' + sliderName}>
{isLoadingGetSelectedSlider ? (
<div className="px-3 py-20">
<BigLoading />
</div>
) : selectedSlider ? (
<SliderForm
mode="edit"
selectedSlider={selectedSlider.data}
updateHandler={updateHandler}
isLoadingDelete={isLoadingDelete}
isLoadingUpdate={isLoadingUpdate}
deleteHandler={deleteHandler}
/>
) : null}
</PageContainer>
</main>
</>
)
}
export default EditSliderPage

View File

@ -0,0 +1,133 @@
'use client'
import Link from 'next/link'
import { useGetCategoriesQuery, useGetSlidersQuery } from '@/store/services'
import { useTitle, useUrlQuery } from '@/hooks'
import { ResponsiveImage, EmptyCustomList, PageContainer, TableSkeleton } from '@/components'
const SlidersPage = () => {
const query = useUrlQuery()
const category_id = query?.category_id
const category_name = query?.category_name
//? Queries
//* Get Categories
const { categories, isLoading: isLoading_get_categories } = useGetCategoriesQuery(undefined, {
selectFromResult: ({ data, isLoading }) => ({
categories: data?.data?.categories
.filter(category => category.level < 2)
.sort((a, b) => a.level - b.level),
isLoading,
skip: !!category_id,
}),
})
//* Get Sliders
const { data: sliders, isLoading: isLoadingGetSliders } = useGetSlidersQuery(
{ category: category_id },
{ skip: !!!category_id }
)
//? Render(s)
const title = category_name ? `分类滑块管理 - ${category_name}` : '滑块管理'
useTitle(title)
const renderContent = () => {
if (isLoading_get_categories || isLoadingGetSliders) {
return (
<tr>
<td colSpan="4">
<TableSkeleton />
</td>
</tr>
)
}
if (categories && !category_id) {
return categories.map(category => (
<tr
className="text-xs text-center transition-colors border-b border-gray-100 md:text-sm hover:bg-gray-50/50"
key={category._id}
>
<td className="w-3/4 px-2 py-4">{category.name}</td>
<td className="px-2 py-4">
<Link
href={`/admin/sliders?category_id=${category._id}&category_name=${category.name}`}
className="bg-rose-50 text-rose-500 rounded-sm py-1 px-1.5 mx-1.5 inline-block"
>
子集
</Link>
</td>
</tr>
))
}
if (sliders && sliders.data && sliders.data.length > 0) {
return sliders.data.map(slider => (
<tr
className="text-xs text-center transition-colors border-b border-gray-100 md:text-sm hover:bg-gray-50/50"
key={slider._id}
>
<td className="px-2 py-4">
<ResponsiveImage
dimensions="h-7 w-32"
className="mx-auto"
src={slider.image?.url}
alt=""
/>
</td>
<td className="w-2/4 px-2 py-4">{slider.title}</td>
<td className="px-2 py-4">
<Link
href={`/admin/sliders/edit/${slider._id}?slider_name=${slider.title}`}
className="bg-rose-50 text-rose-500 rounded-sm py-1 px-1.5 mx-1.5 inline-block"
>
编辑
</Link>
</td>
</tr>
))
} else
return (
<tr>
<td colSpan="4">
<EmptyCustomList />
</td>
</tr>
)
}
return (
<main>
<PageContainer title={title}>
<section className="p-3 mx-auto mb-10 space-y-8">
{category_id && (
<Link
href={`sliders/create?category_id=${category_id}&category_name=${category_name}`}
className="flex items-center px-3 py-2 text-red-600 border-2 border-red-600 rounded-lg max-w-max gap-x-3"
>
添加新滑块
</Link>
)}
<div className="mx-3 overflow-x-auto mt-7 lg:mx-5 xl:mx-10">
<table className="w-full whitespace-nowrap">
<thead className="h-9 bg-emerald-50">
<tr className="text-emerald-500">
{category_name && <th className="border-gray-100 border-x-2">图片</th>}
<th className="px-2 border-gray-100 border-x-2">
{category_name ? '标题' : '分类名称'}
</th>
<th className="border-gray-100 border-x-2">操作</th>
</tr>
</thead>
<tbody className="text-gray-600">{renderContent()}</tbody>
</table>
</div>
</section>
</PageContainer>
</main>
)
}
export default SlidersPage

View File

@ -0,0 +1,117 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import {
PageContainer,
ShowWrapper,
EmptyUsersList,
TableSkeleton,
UsersTable,
ConfirmDeleteModal,
HandleResponse,
Pagination,
} from '@/components'
import { useGetUsersQuery, useDeleteUserMutation } from '@/store/services'
import { useDisclosure, useChangeRoute, useTitle } from '@/hooks'
export default function UsersPage() {
useTitle('用户管理')
//? Assets
const { replace } = useRouter()
const searchParams = useSearchParams()
const page = searchParams.get('page')
const changeRoute = useChangeRoute()
//? Modals
const [isShowConfirmDeleteModal, confirmDeleteModalHandlers] = useDisclosure()
//? State
const [deleteInfo, setDeleteInfo] = useState({
id: '',
})
//? Get User Data
const { data, isSuccess, isFetching, error, isError, refetch } = useGetUsersQuery({
page: page || 1,
})
//? Delete User Query
const [
deleteUser,
{
isSuccess: isSuccess_delete,
isLoading: isLoading_delete,
isError: isError_delete,
error: error_delete,
data: data_delete,
},
] = useDeleteUserMutation()
//? Handlers
const deleteUserHandler = id => {
setDeleteInfo({ id })
confirmDeleteModalHandlers.open()
}
const onConfirmUserDelete = () => deleteUser({ id: deleteInfo.id })
const onCancelUserDelete = () => {
confirmDeleteModalHandlers.close()
setDeleteInfo({ id: '' })
}
return (
<>
<ConfirmDeleteModal
title="用户"
isLoading={isLoading_delete}
isShow={isShowConfirmDeleteModal}
onClose={onCancelUserDelete}
onCancel={onCancelUserDelete}
onConfirm={onConfirmUserDelete}
/>
{/* Handle Delete Response */}
{(isSuccess_delete || isError_delete) && (
<HandleResponse
isError={isError_delete}
isSuccess={isSuccess_delete}
error={error_delete?.data?.message}
message={data_delete?.message}
onSuccess={() => {
onCancelUserDelete()
}}
onError={() => {
onCancelUserDelete()
}}
/>
)}
<main id="_adminUsers">
<PageContainer title="用户管理">
<ShowWrapper
error={error}
isError={isError}
refetch={refetch}
isFetching={isFetching}
isSuccess={isSuccess}
dataLength={data && data.data ? data.data.usersLength : 0}
emptyComponent={<EmptyUsersList />}
loadingComponent={<TableSkeleton />}
>
<UsersTable deleteUserHandler={deleteUserHandler} users={data?.data?.users} />
</ShowWrapper>
{data?.data?.usersLength > 5 && (
<div className="py-4 mx-auto lg:max-w-5xl">
<Pagination
pagination={data?.data?.pagination}
changeRoute={changeRoute}
section="_adminUsers"
/>
</div>
)}
</PageContainer>
</main>
</>
)
}

View File

@ -0,0 +1,94 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { HandleResponse, LoginForm, Logo } from '@/components'
import { useLoginMutation } from '@/store/services'
import { useDispatch } from 'react-redux'
import { useEffect } from 'react'
import { userLogin, showAlert } from '@/store'
import { useTitle } from '@/hooks'
export default function LoginPage() {
useTitle('管理员登录')
//? Assets
const dispatch = useDispatch()
const { push } = useRouter()
//? Login User
const [login, { data, isSuccess, isError, isLoading, error }] = useLoginMutation()
//? Handlers
const submitHander = async ({ email, password }) => {
if (email && password) {
await login({
body: { email, password },
})
}
}
//? Handle Login User Response
useEffect(() => {
if (isSuccess) {
if (data?.data?.user.root || data?.data?.user.role === 'admin') {
dispatch(userLogin(data?.data.token))
dispatch(
showAlert({
status: 'success',
title: data.message,
})
)
push('/admin')
} else {
dispatch(
showAlert({
status: 'error',
title: '您无权访问管理面板',
})
)
}
}
}, [isSuccess])
useEffect(() => {
if (isError && error)
dispatch(
showAlert({
status: 'error',
title: error?.data?.message,
})
)
}, [isError])
return (
<>
<main className="grid items-center min-h-screen">
<section className="container max-w-md px-12 py-6 space-y-6 lg:border lg:border-gray-100 lg:rounded-lg lg:shadow">
<Link passHref href="/">
<Logo className="mx-auto w-48 h-24" />
</Link>
<h1>
<font className="">
<font>登录</font>
</font>
</h1>
<LoginForm isLoading={isLoading} onSubmit={submitHander} />
</section>
<div className="fixed max-w-xs px-2 py-3 bg-white border rounded-lg shadow-lg top-5 right-5">
<h5 className="mb-2 text-amber-600">
您可以使用下面的电子邮件地址和密码来查看管理仪表板
</h5>
<div className="text-left">
<span className="text-sm text-zinc-500">Email: admin@gmail.com</span>
<br />
<span className="text-sm text-zinc-500">Password: 123456</span>
</div>
</div>
</main>
</>
)
}

29
app/(main)/layout.js Normal file
View File

@ -0,0 +1,29 @@
'use client'
import { useEffect, useState } from 'react'
// ? Store
import StoreProvider from 'app/StoreProvider'
// ? Conponents
import { PageLoading, Alert } from '@/components'
export default function Layout({ children }) {
//? Fix Hydration failed
const [showChild, setShowChild] = useState(false)
useEffect(() => {
setShowChild(true)
}, [])
if (!showChild) {
return null
}
return (
<StoreProvider>
{children}
<Alert />
<PageLoading />
</StoreProvider>
)
}

View File

@ -0,0 +1,99 @@
'use client'
import { useTitle } from '@/hooks'
import { Address, Icons, PageContainer, Skeleton, WithAddressModal } from 'components'
import { useUserInfo } from 'hooks'
const BasicAddresses = ({ addressModalProps }) => {
useTitle('地址管理')
const { isAddress, address, isLoading, openAddressModal } = addressModalProps || {}
//? Get User Data
const { userInfo } = useUserInfo()
//? Render(s)
return (
<main>
<PageContainer title="地址">
{isLoading ? (
<section className="flex-1 px-5 ">
<div className="flex justify-between py-4 border-b border-gray-200">
<Skeleton.Item animated="background" height="h-5" width="w-52" />
</div>
<div className="my-2 space-y-3 text-gray-500">
<div className="flex items-center gap-x-2 ">
<Icons.UserLocation className="text-gray-500 icon" />
<Skeleton.Item animated="background" height="h-5" width="w-40" />
</div>
<div className="flex items-center gap-x-2 ">
<Icons.Post className="text-gray-500 icon" />
<Skeleton.Item animated="background" height="h-5" width="w-40" />
</div>
<div className="flex items-center gap-x-2 ">
<Icons.Phone className="text-gray-500 icon" />
<Skeleton.Item animated="background" height="h-5" width="w-40" />
</div>
<div className="flex items-center gap-x-2 ">
<Icons.User className="text-gray-500 icon" />
<Skeleton.Item animated="background" height="h-5" width="w-40" />
</div>
</div>
</section>
) : isAddress ? (
<section className="flex-1 px-5 ">
<div className="flex justify-between py-4 border-b border-gray-200">
<p className="text-sm">{address?.street}</p>
<button onClick={openAddressModal}>
<Icons.Edit className="cursor-pointer icon" />
</button>
</div>
<div className="my-2 space-y-3 text-gray-500">
<div className="flex items-center gap-x-2 ">
<Icons.UserLocation className="text-gray-500 icon" />
<span className="text-xs md:text-sm">
{address?.province.name}, {address?.city.name}, {address?.area.name}
</span>
</div>
<div className="flex items-center gap-x-2 ">
<Icons.Post className="text-gray-500 icon" />
<span className="text-xs md:text-sm">{address?.postalCode}</span>
</div>
{userInfo?.mobile && (
<div className="flex items-center gap-x-2 ">
<Icons.Phone className="text-gray-500 icon" />
<span className="text-xs md:text-sm">{userInfo?.mobile}</span>
</div>
)}
<div className="flex items-center gap-x-2 ">
<Icons.User className="text-gray-500 icon" />
<span className="text-xs md:text-sm">{userInfo?.name}</span>
</div>
</div>
</section>
) : (
<section className="flex flex-col items-center py-20 gap-y-4">
<Address className="h-52 w-52" />
<p>您尚未填写地址</p>
<button
className="flex items-center px-3 py-2 text-red-600 border-2 border-red-600 rounded-lg gap-x-3"
onClick={openAddressModal}
>
<Icons.Location className="text-red-600 icon" />
<span>地址登记</span>
</button>
</section>
)}
</PageContainer>
</main>
)
}
const Addresses = () => (
<WithAddressModal>
<BasicAddresses />
</WithAddressModal>
)
export default Addresses

View File

@ -0,0 +1,9 @@
import { ProfileLayout } from 'components'
export default function Layout({ children }) {
return (
<>
<ProfileLayout>{children}</ProfileLayout>
</>
)
}

View File

@ -0,0 +1,22 @@
'use client'
import { FavoritesListEmpty, PageContainer, ProfileLayout } from 'components'
import { useTitle } from '@/hooks'
const Lists = () => {
useTitle('我的收藏')
//? Render(s)
return (
<main>
<PageContainer title="我的收藏">
<section className="py-20">
<FavoritesListEmpty className="mx-auto h-52 w-52" />
<p className="text-center">您的收藏夹列表为空</p>
<span className="block my-3 text-base text-center text-amber-500">即将上线</span>
</section>
</PageContainer>
</main>
)
}
export default Lists

View File

@ -0,0 +1,62 @@
'use client'
import { useChangeRoute } from 'hooks'
import {
OrderCard,
Pagination,
ShowWrapper,
EmptyOrdersList,
PageContainer,
OrderSkeleton,
} from 'components'
import { useGetOrdersQuery } from '@/store/services'
import { useTitle, useUrlQuery } from '@/hooks'
const Orders = () => {
useTitle('订单管理')
//? Assets
const query = useUrlQuery()
const changeRoute = useChangeRoute()
//? Get Orders Data
const { data, isSuccess, isFetching, error, isError, refetch } = useGetOrdersQuery({
pageSize: 5,
page: query.page ? +query.page : 1,
})
//? Render
return (
<main id="profileOrders">
<PageContainer title="订单历史">
<ShowWrapper
error={error}
isError={isError}
refetch={refetch}
isFetching={isFetching}
isSuccess={isSuccess}
dataLength={data ? data?.data?.ordersLength : 0}
emptyComponent={<EmptyOrdersList />}
loadingComponent={<OrderSkeleton />}
>
<div className="px-4 py-3 space-y-3">
{data?.data?.orders.map(item => <OrderCard key={item._id} order={item} />)}
</div>
</ShowWrapper>
{data && data?.data?.ordersLength > 5 && (
<div className="py-4 mx-auto lg:max-w-5xl">
<Pagination
pagination={data?.data?.pagination}
changeRoute={changeRoute}
section="profileOrders"
client
/>
</div>
)}
</PageContainer>
</main>
)
}
export default Orders

View File

@ -0,0 +1,22 @@
'use client'
import { Orders, ProfileAside } from '@/components'
import { useTitle } from '@/hooks'
import { siteTitle } from '@/utils'
import { useSelector } from 'react-redux'
export default function ProfilePage() {
useTitle(`${siteTitle}-用户中心`)
const { user } = useSelector(state => state.user)
return (
<>
<div className="lg:hidden">
<ProfileAside />
</div>
<div className="hidden lg:block">
<Orders />
</div>
</>
)
}

View File

@ -0,0 +1,78 @@
'use client'
import { useTitle } from '@/hooks'
import { Icons, UserMobileModal, UserNameModal, PageContainer, Skeleton } from 'components'
import { useUserInfo, useDisclosure } from 'hooks'
const PersonalInfo = () => {
useTitle('账户信息')
//? Assets
const [isShowNameModal, nameModalHandlers] = useDisclosure()
const [isShowPhoneModal, phoneModalHandlers] = useDisclosure()
//? Get User Data
const { userInfo, isLoading } = useUserInfo()
//? Local Component
const InfoField = ({ label, info, editHandler, isLoading }) => (
<div className="flex-1 px-5">
<div className="flex items-center justify-between py-4 border-b border-gray-200">
<div>
<span className="text-xs text-gray-700">{label}</span>
{isLoading ? (
<Skeleton.Item animated="background" height="h-5" width="w-44" />
) : (
<p className="h-5 text-sm">{info}</p>
)}
</div>
{isLoading ? null : info ? (
<Icons.Edit className="cursor-pointer icon" onClick={editHandler} />
) : (
<Icons.Plus className="cursor-pointer icon" onClick={editHandler} />
)}
</div>
</div>
)
//? Render(s)
return (
<>
{!isLoading && userInfo && (
<>
<UserNameModal
isShow={isShowNameModal}
onClose={nameModalHandlers.close}
editedData={userInfo.name}
/>
<UserMobileModal
isShow={isShowPhoneModal}
onClose={phoneModalHandlers.close}
editedData={userInfo.mobile}
/>
</>
)}
<main>
<PageContainer title="帐户信息">
<section className="lg:flex">
<InfoField
label="名字和姓氏"
info={userInfo?.name}
editHandler={nameModalHandlers.open}
isLoading={isLoading}
/>
<InfoField
label="电话号码"
info={userInfo?.mobile}
editHandler={phoneModalHandlers.open}
isLoading={isLoading}
/>
</section>
</PageContainer>
</main>
</>
)
}
export default PersonalInfo

View File

@ -0,0 +1,141 @@
'use client'
import { useState } from 'react'
import { useDeleteReviewMutation, useGetReviewsQuery } from '@/store/services'
import {
Pagination,
ReveiwCard,
ShowWrapper,
EmptyCommentsList,
ConfirmDeleteModal,
PageContainer,
HandleResponse,
ReveiwSkeleton,
} from 'components'
import { useDisclosure, useChangeRoute } from 'hooks'
import { useTitle, useUrlQuery } from '@/hooks'
const Reviews = () => {
useTitle('我的评价')
//? Assets
const query = useUrlQuery()
const changeRoute = useChangeRoute()
//? Modals
const [isShowConfirmDeleteModal, confirmDeleteModalHandlers] = useDisclosure()
//? States
const [deleteInfo, setDeleteInfo] = useState({
id: '',
})
//? Queries
//* Delete Review
const [
deleteReview,
{
isSuccess: isSuccessDelete,
isError: isErrorDelete,
error: errorDelete,
data: dataDelete,
isLoading: isLoadingDelete,
},
] = useDeleteReviewMutation()
//* Get Reviews
const { data, isSuccess, isFetching, error, isError, refetch } = useGetReviewsQuery({
page: query.page ? +query.page : 1,
})
//? Handlers
const deleteReviewHandler = id => {
setDeleteInfo({ id })
confirmDeleteModalHandlers.open()
}
const onConfirmDelete = () => deleteReview({ id: deleteInfo.id })
const onCancelDelete = () => {
setDeleteInfo({ id: '' })
confirmDeleteModalHandlers.close()
}
const onSuccessDelete = () => {
confirmDeleteModalHandlers.close()
setDeleteInfo({ id: '' })
}
const onErrorDelete = () => {
confirmDeleteModalHandlers.close()
setDeleteInfo({ id: '' })
}
//? Render(s)
return (
<>
<ConfirmDeleteModal
title="评价"
isLoading={isLoadingDelete}
isShow={isShowConfirmDeleteModal}
onClose={confirmDeleteModalHandlers.close}
onCancel={onCancelDelete}
onConfirm={onConfirmDelete}
/>
{/* Handle Delete Response */}
{(isSuccessDelete || isErrorDelete) && (
<HandleResponse
isError={isErrorDelete}
isSuccess={isSuccessDelete}
error={errorDelete?.data?.message}
message={dataDelete?.message}
onSuccess={onSuccessDelete}
onError={onErrorDelete}
/>
)}
<main id="profileReviews">
<PageContainer title="我的评价">
<ShowWrapper
error={error}
isError={isError}
refetch={refetch}
isFetching={isFetching}
isSuccess={isSuccess}
dataLength={data ? data?.data?.reviewsLength : 0}
emptyComponent={<EmptyCommentsList />}
loadingComponent={<ReveiwSkeleton />}
>
<div className="px-4 py-3 space-y-3 ">
{data &&
data.data.reviews.map(item => (
<ReveiwCard
deleteReviewHandler={deleteReviewHandler}
key={item._id}
item={item}
/>
))}
</div>
</ShowWrapper>
{data && data?.data?.reviewsLength > 5 && (
<div className="py-4 mx-auto lg:max-w-5xl">
<Pagination
pagination={data?.data?.pagination}
changeRoute={changeRoute}
section="profileReviews"
client
/>
</div>
)}
</PageContainer>
</main>
</>
)
}
export default Reviews

View File

@ -0,0 +1,57 @@
'use client'
import Link from 'next/link'
import { useAppSelector } from 'hooks'
import { truncate } from 'utils'
import { EmptyCart, PageContainer, ResponsiveImage } from 'components'
import { useTitle } from '@/hooks'
const UserHistory = () => {
useTitle('最近访问')
//? Store
const { lastSeen } = useAppSelector(state => state.user)
//? selector
return (
<main>
<PageContainer title="最近访问">
{lastSeen.length > 0 ? (
<div className="px-3 space-y-4 md:py-4 md:space-y-0 md:grid md:grid-cols-2 md:gap-x-2 lg:grid-cols-3 md:gap-y-3">
{lastSeen.map(item => (
<article
className="border-b md:hover:shadow-3xl md:h-64 md:border-0 "
key={item.productID}
>
<Link
href={`/products/${item.productID}`}
className="flex items-center gap-4 py-4 md:items-start md:flex-col"
>
<ResponsiveImage
dimensions="w-36 h-36"
className="md:mx-auto"
src={item.image.url}
alt={item.title}
/>
<h5 className="flex-1 px-3 text-left text-gray-800 leadiri-6 md:h-32">
{truncate(item.title, 80)}
</h5>
</Link>
</article>
))}
</div>
) : (
<section className="py-20">
<EmptyCart className="mx-auto h-52 w-52" />
<p className="text-center">您的最近访问列表为空</p>
</section>
)}
</PageContainer>
</main>
)
}
export default UserHistory

15
app/StoreProvider.js Normal file
View File

@ -0,0 +1,15 @@
'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { store } from 'store'
export default function StoreProvider({ children }) {
const storeRef = useRef()
if (!storeRef.current) {
// Create the store instance the first time this renders
storeRef.current = store
}
return <Provider store={storeRef.current}>{children}</Provider>
}

View File

@ -0,0 +1,24 @@
import joi from 'joi'
import { usersRepo } from 'helpers'
import { apiHandler, setJson } from 'helpers/api'
const login = apiHandler(
async req => {
const body = await req.json()
const result = await usersRepo.authenticate(body)
return setJson({
data: result,
message: '登录成功',
})
},
{
schema: joi.object({
email: joi.string().required(),
password: joi.string().min(6).required(),
}),
}
)
export const POST = login
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,24 @@
import joi from 'joi'
import { usersRepo } from 'helpers'
import { apiHandler, setJson } from 'helpers/api'
const register = apiHandler(
async req => {
const body = await req.json()
const result = await usersRepo.create(body)
return setJson({
data: result,
})
},
{
schema: joi.object({
name: joi.string().required(),
email: joi.string().required(),
password: joi.string().min(6).required(),
}),
}
)
export const POST = register
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,28 @@
import joi from 'joi'
import { usersRepo } from 'helpers'
import { apiHandler } from 'helpers/api'
import { setJson } from '@/helpers/api'
const getUertInfo = apiHandler(
async req => {
const userId = req.headers.get('userId')
const user = await usersRepo.getById(userId)
return setJson({
data: {
name: user.name,
email: user.email,
role: user.role,
root: user.root,
address: user.address,
mobile: user.mobile,
},
})
},
{
isJwt: true,
}
)
export const GET = getUertInfo
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,54 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { bannerRepo } from '@/helpers'
const getDetail = apiHandler(async (req, { params }) => {
const { id } = params
const result = await bannerRepo.getById(id)
return setJson({
data: result,
})
})
const update = apiHandler(
async (req, { params }) => {
const { id } = params
const body = await req.json()
await bannerRepo.update(id, body)
return setJson({
message: '更新成功',
})
},
{
isJwt: true,
identity: 'root',
schema: joi.object({
category_id: joi.string().required(),
image: joi.object().required(),
isPublic: joi.boolean().required(),
title: joi.string().required(),
type: joi.string().required(),
uri: joi.string().required(),
}),
}
)
const _delete = apiHandler(
async (req, { params }) => {
const { id } = params
await bannerRepo.delete(id)
return setJson({
message: '删除成功',
})
},
{
isJwt: true,
identity: 'root',
}
)
export const GET = getDetail
export const PUT = update
export const DELETE = _delete
export const dynamic = 'force-dynamic'

50
app/api/banner/route.js Normal file
View File

@ -0,0 +1,50 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { bannerRepo, getQuery } from '@/helpers'
const getAll = apiHandler(
async req => {
const query = getQuery(req)
const category = query?.category
const result = await bannerRepo.getAll(
{},
{
category_id: category,
}
)
return setJson({
data: result,
})
},
{
isJwt: true,
identity: 'admin',
}
)
const create = apiHandler(
async req => {
const body = await req.json()
await bannerRepo.create(body)
return setJson({
message: '新增成功',
})
},
{
isJwt: true,
identity: 'root',
schema: joi.object({
category_id: joi.string().required(),
image: joi.object().required(),
isPublic: joi.boolean().required(),
title: joi.string().required(),
type: joi.string().required(),
uri: joi.string().required(),
}),
}
)
export const GET = getAll
export const POST = create
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,45 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { categoryRepo } from '@/helpers'
const deleteCategory = apiHandler(
async (req, { params }) => {
const { id } = params
await categoryRepo.delete(id)
return setJson({
message: '删除成功',
})
},
{
isJwt: true,
identity: 'root',
}
)
const updateCategory = apiHandler(
async (req, { params }) => {
const { id } = params
const body = await req.json()
await categoryRepo.update(id, body)
return setJson({
message: '更新成功',
})
},
{
isJwt: true,
identity: 'admin',
schema: joi.object({
name: joi.string().required(),
slug: joi.string().required(),
image: joi.string().required(),
colors: joi.object(),
level: joi.number().required(),
parent: joi.string(),
}),
}
)
export const DELETE = deleteCategory
export const PUT = updateCategory
export const dynamic = 'force-dynamic'

62
app/api/category/route.js Normal file
View File

@ -0,0 +1,62 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { categoryRepo } from '@/helpers'
const getCategory = apiHandler(async req => {
const result = await categoryRepo.getAll()
async function getCategoriesWithChildren() {
const allCategories = await categoryRepo.getAll()
function findChildren(category) {
const children = allCategories.filter(c => c.parent && c.parent === category._id)
if (children.length > 0) {
category.children = children.map(child => {
return findChildren(child)
})
}
return category
}
const rootCategories = allCategories.filter(c => !c.parent)
const categoriesWithChildren = rootCategories.map(category => {
return findChildren(category)
})
return categoriesWithChildren
}
const categoriesList = await getCategoriesWithChildren()
return setJson({
data: {
categories: result,
categoriesList: categoriesList[0],
},
})
})
const createCategory = apiHandler(
async req => {
const body = await req.json()
await categoryRepo.create(body)
return setJson({
message: '创建分类成功',
})
},
{
isJwt: true,
identity: 'admin',
schema: joi.object({
name: joi.string().required(),
slug: joi.string().required(),
image: joi.string().required(),
colors: joi.object().required(),
level: joi.number().required(),
parent: joi.string(),
}),
}
)
export const GET = getCategory
export const POST = createCategory
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,52 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { detailsRepo } from '@/helpers'
const getDetails = apiHandler(async (req, { params }) => {
const { id } = params
const result = await detailsRepo.getById(id)
return setJson({
data: result,
})
})
const updateDetails = apiHandler(
async (req, { params }) => {
const { id } = params
const body = await req.json()
await detailsRepo.update(id, body)
return setJson({
message: '商品更新成功',
})
},
{
isJwt: true,
identity: 'admin',
schema: joi.object({
category_id: joi.string().required(),
info: joi.array().required(),
optionsType: joi.string().required(),
specification: joi.array().required(),
}),
}
)
const deleteDetails = apiHandler(
async (req, { params }) => {
const { id } = params
await detailsRepo.delete(id)
return setJson({
message: '商品已成功删除',
})
},
{
isJwt: true,
identity: 'root',
}
)
export const GET = getDetails
export const PUT = updateDetails
export const DELETE = deleteDetails
export const dynamic = 'force-dynamic'

41
app/api/details/route.js Normal file
View File

@ -0,0 +1,41 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { detailsRepo } from '@/helpers'
const getAllDetails = apiHandler(
async req => {
const result = await detailsRepo.getAll()
return setJson({
data: result,
})
},
{
isJwt: true,
identity: 'admin',
}
)
const createDetails = apiHandler(
async req => {
const body = await req.json()
await detailsRepo.create(body)
return setJson({
message: '新增商品成功',
})
},
{
isJwt: true,
identity: 'admin',
schema: joi.object({
category_id: joi.string().required(),
info: joi.array().required(),
optionsType: joi.string().required(),
specification: joi.array().required(),
}),
}
)
export const GET = getAllDetails
export const POST = createDetails
export const dynamic = 'force-dynamic'

48
app/api/feed/route.js Normal file
View File

@ -0,0 +1,48 @@
import { setJson, apiHandler } from '@/helpers/api'
import { bannerRepo, categoryRepo, sliderRepo } from '@/helpers'
const getFeed = apiHandler(
async req => {
const currentCategory = await categoryRepo.getOne({
parent: undefined,
})
const childCategories = await categoryRepo.getAll(
{},
{
parent: currentCategory?._id,
}
)
const sliders = await sliderRepo.getAll({}, { category_id: currentCategory?._id })
const bannerOneType = await bannerRepo.getAll(
{},
{
category_id: currentCategory?._id,
type: 'one',
}
)
const bannerTwoType = await bannerRepo.getAll(
{},
{
category_id: currentCategory?._id,
type: 'two',
}
)
return setJson({
data: {
currentCategory,
childCategories,
sliders,
bannerOneType,
bannerTwoType,
},
})
},
{
isJwt: false,
}
)
export const GET = getFeed
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,29 @@
import { setJson, apiHandler } from '@/helpers/api'
import { orderRepo } from '@/helpers'
const getOrder = apiHandler(async (req, { params }) => {
const { id } = params
const result = await orderRepo.getById(id)
return setJson({
data: result,
})
})
const updateOrder = apiHandler(
async (req, { params }) => {
const { id } = params
const body = await req.json()
await orderRepo.update(id, body)
return setJson({
message: '已经通过确认',
})
},
{
isJwt: true,
identity: 'admin',
}
)
export const PATCH = updateOrder
export const GET = getOrder
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,24 @@
import { setJson, apiHandler } from '@/helpers/api'
import { getQuery, orderRepo } from '@/helpers'
const getOrders = apiHandler(
async req => {
const query = getQuery(req)
const page = query.page ? +query.page : 1
const page_size = query.page_size ? +query.page_size : 10
const result = await orderRepo.getAll({
page,
page_size,
})
return setJson({
data: result,
})
},
{
isJwt: true,
identity: 'admin',
}
)
export const GET = getOrders
export const dynamic = 'force-dynamic'

52
app/api/order/route.js Normal file
View File

@ -0,0 +1,52 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { getQuery, orderRepo } from '@/helpers'
const getOrders = apiHandler(
async req => {
const query = getQuery(req)
const page = query.page ? +query.page : 1
const page_size = query.page_size ? +query.page_size : 10
const userId = req.headers.get('userId')
const result = await orderRepo.getAll(
{
page,
page_size,
},
{ user: userId }
)
return setJson({
data: result,
})
},
{
isJwt: true,
}
)
const createOrder = apiHandler(
async req => {
const userId = req.headers.get('userId')
const body = await req.json()
await orderRepo.create(userId, body)
return setJson({
message: '创建订单成功',
})
},
{
isJwt: true,
schema: joi.object({
address: joi.object().required(),
mobile: joi.string(),
cart: joi.array().required(),
totalItems: joi.number().required(),
totalPrice: joi.number().required(),
totalDiscount: joi.number().required(),
paymentMethod: joi.string().required(),
}),
}
)
export const GET = getOrders
export const POST = createOrder
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,60 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { productRepo } from '@/helpers'
const getProduct = apiHandler(async (req, { params }) => {
const { id } = params
const result = await productRepo.getById(id)
return setJson({
data: result,
})
})
const updateProduct = apiHandler(
async (req, { params }) => {
const { id } = params
const body = await req.json()
await productRepo.update(id, body)
return setJson({
message: '商品更新成功',
})
},
{
isJwt: true,
identity: 'admin',
schema: joi.object({
title: joi.string().required(),
price: joi.number().required(),
category: joi.array().required(),
images: joi.array().required(),
info: joi.array().required(),
specification: joi.array().required(),
inStock: joi.number(),
description: joi.string().allow(''),
discount: joi.number(),
sizes: joi.array(),
colors: joi.array(),
category_levels: joi.object(),
}),
}
)
const deleteProduct = apiHandler(
async (req, { params }) => {
const { id } = params
await productRepo.delete(id)
return setJson({
message: '商品已成功删除',
})
},
{
isJwt: true,
identity: 'root',
}
)
export const GET = getProduct
export const PUT = updateProduct
export const DELETE = deleteProduct
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,23 @@
import { setJson, apiHandler } from '@/helpers/api'
import { getQuery, productRepo } from '@/helpers'
const itemDetail = apiHandler(async req => {
const { id } = getQuery(req)
const result = await productRepo.getItemDetail(id)
// return new Promise((resolve, reject) => {
// setTimeout(() => {
// resolve(
// setJson({
// data: result,
// })
// )
// }, 3000)
// })
return setJson({
data: result,
})
})
export const GET = itemDetail
export const dynamic = 'force-dynamic'

114
app/api/products/route.js Normal file
View File

@ -0,0 +1,114 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { db, getQuery, productRepo } from '@/helpers'
import Category from '@/models/Category'
const getAllProduct = apiHandler(async req => {
const query = getQuery(req)
const page = query.page ? +query.page : 1
const page_size = query.page_size ? +query.page_size : 10
const sort = query.sort ? +query.sort : 1
const { category, search, inStock, discount, price } = query
//? Filters
await db.connect()
const currentCategory = await Category.findOne({ slug: category })
await db.disconnect()
const categoryFilter = currentCategory
? {
category: { $in: currentCategory._id.toString() },
}
: {}
const searchFilter = search
? {
title: {
$regex: search,
$options: 'i',
},
}
: {}
const inStockFilter = inStock === 'true' ? { inStock: { $gte: 1 } } : {}
const discountFilter = discount === 'true' ? { discount: { $gte: 1 }, inStock: { $gte: 1 } } : {}
const priceFilter = price
? {
price: {
$gte: +price.split('-')[0],
$lte: +price.split('-')[1],
},
}
: {}
//? Sort
const order =
sort === 3
? { price: 1 }
: sort === 4
? { price: -1 }
: sort === 2
? { sold: -1 }
: sort === 1
? { createdAt: -1 }
: sort === 5
? { rating: -1 }
: sort === 6
? { discount: -1 }
: { _id: -1 }
const result = await productRepo.getAll(
{
page,
page_size,
},
{
...categoryFilter,
...inStockFilter,
...discountFilter,
...priceFilter,
...searchFilter,
},
order
)
return setJson({
data: result,
})
})
const createProduct = apiHandler(
async req => {
const body = await req.json()
await productRepo.create(body)
return setJson({
message: '新增商品成功',
})
},
{
isJwt: true,
identity: 'admin',
schema: joi.object({
title: joi.string().required(),
price: joi.number().required(),
category: joi.array().required(),
images: joi.array().required(),
info: joi.array().required(),
specification: joi.array().required(),
inStock: joi.number(),
description: joi.string().allow(''),
discount: joi.number(),
sizes: joi.array(),
colors: joi.array(),
category_levels: joi.object(),
}),
}
)
export const GET = getAllProduct
export const POST = createProduct
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,74 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { productRepo, reviewRepo } from '@/helpers'
const getDetail = apiHandler(async (req, { params }) => {
const { id } = params
const result = await reviewRepo.getById(id)
return setJson({
data: result,
})
})
const update = apiHandler(
async (req, { params }) => {
const { id } = params
const body = await req.json()
const review = await reviewRepo.update(id, body)
const product = await productRepo.getById(review.product)
const reviews = await reviewRepo.getAll(
{ page: 0, page_size: 0 },
{
product: product?._id,
}
)
if (product && reviews.reviews.length) {
let { totalRating, totalReviews } = reviews.reviews.reduce(
(total, item) => {
if (item.status === 2) {
total.totalRating += item.rating
total.totalReviews += 1
}
return total
},
{ totalRating: 0, totalReviews: 0 }
)
await productRepo.update(review.product, {
numReviews: totalReviews,
rating: totalReviews ? totalRating / totalReviews : 0,
})
}
return setJson({
message: '更新成功',
})
},
{
isJwt: true,
identity: 'admin',
schema: joi.object({
status: joi.number().required(),
}),
}
)
const _delete = apiHandler(
async (req, { params }) => {
const { id } = params
await reviewRepo.delete(id)
return setJson({
message: '删除成功',
})
},
{
isJwt: true,
identity: 'root',
}
)
export const GET = getDetail
export const PATCH = update
export const DELETE = _delete
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,27 @@
import { getQuery, reviewRepo } from '@/helpers'
import { apiHandler, setJson } from '@/helpers/api'
const getAll = apiHandler(
async req => {
const query = getQuery(req)
const page = query.page ? +query.page : 1
const page_size = query.page_size ? +query.page_size : 10
const result = await reviewRepo.getAll({
page,
page_size,
})
return setJson({
data: result,
})
},
{
isJwt: true,
identity: 'admin',
}
)
export const GET = getAll
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,33 @@
import { getQuery, reviewRepo } from '@/helpers'
const { apiHandler, setJson } = require('@/helpers/api')
const getReviews = apiHandler(
async (req, { params }) => {
const { id } = params
const query = getQuery(req)
const page = query.page ? +query.page : 1
const page_size = query.page_size ? +query.page_size : 5
const result = await reviewRepo.getAll(
{
page,
page_size,
},
{
product: id,
status: 2,
}
)
return setJson({
data: result,
})
},
{
isJwt: false,
}
)
export const GET = getReviews
export const dynamic = 'force-dynamic'

57
app/api/reviews/route.js Normal file
View File

@ -0,0 +1,57 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { getQuery, reviewRepo } from '@/helpers'
const getAll = apiHandler(
async req => {
const userId = req.headers.get('userId')
const query = getQuery(req)
const page = query.page ? +query.page : 1
const page_size = query.page_size ? +query.page_size : 10
const result = await reviewRepo.getAll(
{
page,
page_size,
},
{
user: userId,
}
)
return setJson({
data: result,
})
},
{
isJwt: true,
}
)
const create = apiHandler(
async req => {
const userId = req.headers.get('userId')
const body = await req.json()
await reviewRepo.create(userId, body)
return setJson({
message: '新增成功',
})
},
{
isJwt: true,
schema: joi.object({
product: joi.string().required(),
title: joi.string().required(),
rating: joi.number().required(),
comment: joi.string().required(),
negativePoints: joi.array(),
positivePoints: joi.array(),
}),
}
)
export const GET = getAll
export const POST = create
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,53 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { sliderRepo } from '@/helpers'
const getDetail = apiHandler(async (req, { params }) => {
const { id } = params
const result = await sliderRepo.getById(id)
return setJson({
data: result,
})
})
const update = apiHandler(
async (req, { params }) => {
const { id } = params
const body = await req.json()
await sliderRepo.update(id, body)
return setJson({
message: '更新成功',
})
},
{
isJwt: true,
identity: 'admin',
schema: joi.object({
category_id: joi.string().required(),
image: joi.object().required(),
isPublic: joi.boolean().required(),
title: joi.string().required(),
uri: joi.string().required(),
}),
}
)
const _delete = apiHandler(
async (req, { params }) => {
const { id } = params
await sliderRepo.delete(id)
return setJson({
message: '删除成功',
})
},
{
isJwt: true,
identity: 'root',
}
)
export const GET = getDetail
export const PUT = update
export const DELETE = _delete
export const dynamic = 'force-dynamic'

49
app/api/slider/route.js Normal file
View File

@ -0,0 +1,49 @@
import joi from 'joi'
import { setJson, apiHandler } from '@/helpers/api'
import { sliderRepo, getQuery } from '@/helpers'
const getAll = apiHandler(
async req => {
const query = getQuery(req)
const category = query?.category
const result = await sliderRepo.getAll(
{},
{
category_id: category,
}
)
return setJson({
data: result,
})
},
{
isJwt: true,
identity: 'admin',
}
)
const create = apiHandler(
async req => {
const body = await req.json()
await sliderRepo.create(body)
return setJson({
message: '新增成功',
})
},
{
isJwt: true,
identity: 'admin',
schema: joi.object({
category_id: joi.string().required(),
image: joi.object().required(),
isPublic: joi.boolean().required(),
title: joi.string().required(),
uri: joi.string().required(),
}),
}
)
export const GET = getAll
export const POST = create
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,28 @@
import { apiHandler, setJson } from '@/helpers/api'
import { STS } from 'ali-oss'
const storeSTS = new STS({
accessKeyId: process.env.NEXT_PUBLIC_ALI_ACCESS_KEY,
accessKeySecret: process.env.NEXT_PUBLIC_ALI_SECRET_KEY,
})
const getToken = apiHandler(
async req => {
const result = await storeSTS.assumeRole(
process.env.NEXT_PUBLIC_ALI_ACS_RAM_NAME,
'',
'3000',
'sessiontest'
)
return setJson({
data: { ...result.credentials },
})
},
{
isJwt: true,
identity: 'admin',
}
)
export const GET = getToken
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,42 @@
import joi from 'joi'
import { usersRepo } from 'helpers'
import { apiHandler } from 'helpers/api'
import { setJson } from '@/helpers/api'
const updateRole = apiHandler(
async (req, { params }) => {
const { id } = params
const body = await req.json()
await usersRepo.update(id, body)
return setJson({
message: '更新成功',
})
},
{
isJwt: true,
schema: joi.object({
role: joi.string().required().valid('user', 'admin'),
}),
identity: 'root',
}
)
const deleteUser = apiHandler(
async (req, { params }) => {
const { id } = params
await usersRepo.delete(id)
return setJson({
message: '用户信息已经删除',
})
},
{
isJwt: true,
identity: 'root',
}
)
export const PATCH = updateRole
export const DELETE = deleteUser
export const dynamic = 'force-dynamic'

View File

@ -0,0 +1,26 @@
import joi from 'joi'
import { usersRepo } from 'helpers'
import { apiHandler } from 'helpers/api'
import { setJson } from '@/helpers/api'
const resetPassword = apiHandler(
async req => {
const userId = req.headers.get('userId')
const { password } = await req.json()
await usersRepo.resetPassword(userId, password)
return setJson({
message: '密码更新成功',
})
},
{
isJwt: true,
schema: joi.object({
password: joi.string().min(6).required(),
}),
}
)
export const PATCH = resetPassword
export const dynamic = 'force-dynamic'

48
app/api/user/route.js Normal file
View File

@ -0,0 +1,48 @@
import joi from 'joi'
import { usersRepo } from 'helpers'
import { apiHandler } from 'helpers/api'
import { setJson } from '@/helpers/api'
const getUsers = apiHandler(
async req => {
const searchParams = req.nextUrl.searchParams
const page = +searchParams.get('page') || 1
const page_size = +searchParams.get('page_size') || 5
const result = await usersRepo.getAll({
page,
page_size,
})
return setJson({
data: result,
})
},
{
isJwt: true,
identity: 'admin',
}
)
const uploadInfo = apiHandler(
async req => {
const userId = req.headers.get('userId')
const body = await req.json()
const result = await usersRepo.update(userId, body)
return setJson({
data: result,
})
},
{
isJwt: true,
schema: joi.object({
name: joi.string(),
address: joi.object(),
mobile: joi.string(),
}),
}
)
export const GET = getUsers
export const PATCH = uploadInfo
export const dynamic = 'force-dynamic'

21
app/layout.js Normal file
View File

@ -0,0 +1,21 @@
import '/styles/main.css'
import '/styles/browser-styles.css'
import '/styles/swiper.css'
import { enSiteTitle, siteDescription, siteTitle } from '@/utils'
export const metadata = {
title: `${siteTitle} | ${enSiteTitle}`,
description: `${siteDescription}`,
icons: {
icon: '/favicon.ico',
},
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

26
app/not-found.js Normal file
View File

@ -0,0 +1,26 @@
export const metadata = {
title: '404 Not Found!',
}
import StoreProvider from 'app/StoreProvider'
import { ArrowLink, ResponsiveImage, ClientLayout } from '@/components'
export default function NotFoundPage() {
//? Render(s)
return (
<StoreProvider>
<ClientLayout>
<main className="flex flex-col items-center justify-center py-8 gap-y-6 xl:mt-28">
<p className="text-base font-semibold text-black">404 Not Found!</p>
<ArrowLink path="/">返回首页</ArrowLink>
<ResponsiveImage
dimensions="w-full max-w-lg h-72"
src="/icons/page-not-found.png"
layout="fill"
alt="404"
/>
</main>
</ClientLayout>
</StoreProvider>
)
}

1
commitlint.config.js Normal file
View File

@ -0,0 +1 @@
module.exports = { extends: ['@commitlint/config-conventional'] }

48
components/AddressBar.jsx Normal file
View File

@ -0,0 +1,48 @@
import { Icons, Skeleton, WithAddressModal } from 'components'
const BasicAddressBar = ({ addressModalProps }) => {
//? Props
const { address, isLoading, isVerify, openAddressModal, isAddress } = addressModalProps || {}
//? Render(s)
if (!isVerify) {
return null
} else if (isLoading) {
return <Skeleton.Item animated="background" height="h-5 lg:h-6" width="w-3/4 lg:w-1/4" />
} else if (!isAddress) {
return (
<button
type="button"
onClick={openAddressModal}
className="flex items-center w-full gap-x-1 lg:w-fit"
>
<Icons.Location2 className="icon" />
<span>请选择您所在的城市</span>
<Icons.ArrowRight2 className="mr-auto icon" />
</button>
)
} else if (isAddress) {
return (
<button
type="button"
onClick={openAddressModal}
className="flex items-center w-full gap-x-1 lg:w-fit"
>
<Icons.Location2 className="icon" />
<span>
发送{address?.province.name}, {address?.city.name}, {address?.area.name}
</span>
<Icons.ArrowRight2 className="mr-auto icon" />
</button>
)
}
}
export default function AddressBar() {
return (
<WithAddressModal>
<BasicAddressBar />
</WithAddressModal>
)
}

63
components/Alert.js Normal file
View File

@ -0,0 +1,63 @@
'use client'
import { useEffect } from 'react'
import Image from 'next/image'
import { useDispatch, useSelector } from 'react-redux'
import { removeAlert } from 'store'
export default function Alert() {
//? Store
const { isShow, status, title } = useSelector(state => state.alert)
//? Assets
const dispatch = useDispatch()
let IconSrc
switch (status) {
case 'error':
IconSrc = '/icons/error.svg'
break
case 'success':
IconSrc = '/icons/success.svg'
break
case 'exclamation':
IconSrc = '/icons/exclamation.svg'
break
case 'question':
IconSrc = '/icons/question.svg'
break
default:
IconSrc = '/icons/nothing.svg'
break
}
//? Re-Renders
useEffect(() => {
if (isShow) {
const timeout = setTimeout(() => {
dispatch(removeAlert())
}, 2000)
return () => clearTimeout(timeout)
}
}, [isShow])
//? Render(s)
return (
<div
className={`${
isShow ? 'opacity-100 visible' : 'opacity-0 invisible '
} transition-all duration-500 fixed inset-0 z-40`}
>
<div className="w-full h-full bg-gray-400/20" onClick={() => dispatch(removeAlert())} />
<div
className={`${
isShow ? 'top-40' : '-top-full'
} max-w-md fixed transition-all duration-700 left-0 right-0 mx-auto z-40`}
>
<div className="p-3 mx-2 text-center bg-white rounded-md shadow h-fit">
<Image className="mx-auto" src={IconSrc} width="80" height="80" alt={status} />
<p className="mt-2">{title}</p>
</div>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More