Compare commits
35 Commits
38d10217f1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c8dcf9db7 | |||
|
|
4a8e761b3d | ||
| 0c8e0893c7 | |||
|
|
7df60fe004 | ||
| daab622638 | |||
|
|
ea7861b63a | ||
|
|
069f58075b | ||
|
|
edef4273c0 | ||
|
|
e3091494b1 | ||
|
|
3859099074 | ||
|
|
54c84dbc87 | ||
| 1a0cc9376f | |||
| b730945d34 | |||
| 17e27fca70 | |||
|
|
af3fa26f3b | ||
|
|
eec883ac32 | ||
| 49ca9b9ae3 | |||
|
|
45dc4cbaaa | ||
| 877bbc582e | |||
| 49cda54644 | |||
| 2a1c2716ba | |||
| de8381f094 | |||
| 6943d3d6ab | |||
| db92aa24d0 | |||
|
|
3704ef18d2 | ||
| 928250a291 | |||
| 81ce79957d | |||
| 9e37299966 | |||
| c7ff6f8d13 | |||
|
|
a51566ffae | ||
| ca1ebd2985 | |||
| 2f7b1d4653 | |||
| ad9226a2f1 | |||
| 4173cbef9b | |||
| 58bdea1e60 |
@@ -1 +1 @@
|
|||||||
VITE_API_URL=http://localhost:8080
|
VITE_API_URL=http://localhost:8088
|
||||||
|
|||||||
55
.gitea/workflows/gitea-ci.yml
Normal file
55
.gitea/workflows/gitea-ci.yml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Test CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: rpi5
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_VOLUME: ${{ vars.DOCKER_VOLUME }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check PWD
|
||||||
|
run: |
|
||||||
|
echo "Docker volume: $DOCKER_VOLUME"
|
||||||
|
echo "PWD: $PWD"
|
||||||
|
|
||||||
|
- name: Validate Node Environment
|
||||||
|
run: |
|
||||||
|
if ! command -v node &> /dev/null
|
||||||
|
then
|
||||||
|
echo "Error: Node.js not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Node.js version: $(node -v)"
|
||||||
|
|
||||||
|
- name: Restore node_modules
|
||||||
|
id: cache-node
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-package-v1-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-package-v1-
|
||||||
|
|
||||||
|
- name: Install Dependencies with Npm
|
||||||
|
if: steps.cache-node.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
npm install
|
||||||
|
ls .
|
||||||
|
|
||||||
|
- name: Build Nestjs project
|
||||||
|
run: |
|
||||||
|
npm run build
|
||||||
|
ls .
|
||||||
|
|
||||||
|
- name: Deploy dist
|
||||||
|
run: |
|
||||||
|
cp -r dist/* $DOCKER_VOLUME/scheduler/front/service/
|
||||||
|
ls $DOCKER_VOLUME/scheduler/front/service/
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
stages:
|
|
||||||
- build
|
|
||||||
|
|
||||||
variables:
|
|
||||||
GIT_STRATEGY: clone
|
|
||||||
GIT_CHECKOUT: "true"
|
|
||||||
GIT_SSL_NO_VERIFY: "true"
|
|
||||||
|
|
||||||
build:
|
|
||||||
stage: build
|
|
||||||
image: node:25.1.0
|
|
||||||
tags:
|
|
||||||
- local-runner
|
|
||||||
script:
|
|
||||||
- export PATH="$PATH:$NVM_HOME/versions/node/v25.1.0/bin"
|
|
||||||
- [ -s "$NVM_HOME/nvm.sh" ] && \. "$NVM_HOME/nvm.sh"
|
|
||||||
- node -v
|
|
||||||
- npm install
|
|
||||||
- npm run build
|
|
||||||
- pwd
|
|
||||||
- ls
|
|
||||||
- docker cp -r $pwd/dist/* scheduler_front:/usr/share/nginx/html/
|
|
||||||
@@ -18,5 +18,7 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"registries": {}
|
"registries": {
|
||||||
|
"@reui": "https://reui.io/r/{name}.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
307
package-lock.json
generated
307
package-lock.json
generated
@@ -37,6 +37,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/cli": "^4.1.16",
|
"@tailwindcss/cli": "^4.1.16",
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.12",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
@@ -108,6 +109,7 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -3448,6 +3450,7 @@
|
|||||||
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -3458,6 +3461,7 @@
|
|||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -3468,6 +3472,7 @@
|
|||||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -3518,6 +3523,7 @@
|
|||||||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.46.2",
|
"@typescript-eslint/scope-manager": "8.46.2",
|
||||||
"@typescript-eslint/types": "8.46.2",
|
"@typescript-eslint/types": "8.46.2",
|
||||||
@@ -3770,6 +3776,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3839,6 +3846,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.21",
|
"version": "10.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||||
@@ -3877,6 +3890,17 @@
|
|||||||
"postcss": "^8.1.0"
|
"postcss": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||||
|
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.6",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -3937,6 +3961,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.19",
|
"baseline-browser-mapping": "^2.8.19",
|
||||||
"caniuse-lite": "^1.0.30001751",
|
"caniuse-lite": "^1.0.30001751",
|
||||||
@@ -3951,6 +3976,19 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -4056,6 +4094,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -4268,6 +4318,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||||
@@ -4296,6 +4355,20 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.241",
|
"version": "1.5.241",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.241.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.241.tgz",
|
||||||
@@ -4307,7 +4380,8 @@
|
|||||||
"version": "8.6.0",
|
"version": "8.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/embla-carousel-react": {
|
"node_modules/embla-carousel-react": {
|
||||||
"version": "8.6.0",
|
"version": "8.6.0",
|
||||||
@@ -4344,6 +4418,51 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.11",
|
"version": "0.25.11",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
|
||||||
@@ -4414,6 +4533,7 @@
|
|||||||
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -4724,6 +4844,42 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||||
@@ -4752,6 +4908,15 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@@ -4762,6 +4927,30 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-nonce": {
|
"node_modules/get-nonce": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
@@ -4771,6 +4960,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -4797,6 +4999,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
@@ -4820,6 +5034,45 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -5339,6 +5592,15 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -5362,6 +5624,27 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -5578,6 +5861,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -5621,6 +5905,12 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -5657,6 +5947,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -5687,6 +5978,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -5699,6 +5991,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -6083,9 +6376,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.3.1",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||||
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
|
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -6155,6 +6448,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -6222,6 +6516,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6394,6 +6689,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
|
||||||
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
|
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -6485,6 +6781,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"local": "vite --mode local",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/cli": "^4.1.16",
|
"@tailwindcss/cli": "^4.1.16",
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -55,7 +57,7 @@
|
|||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.12",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
|
|||||||
@@ -117,4 +117,4 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
41
src/App.tsx
41
src/App.tsx
@@ -1,22 +1,47 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import reactLogo from './assets/react.svg';
|
|
||||||
import viteLogo from '/vite.svg';
|
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import SignUpPage from './ui/page/signup/SignUpPage';
|
import SignUpPage from './ui/page/account/signup/SignUpPage';
|
||||||
import Layout from './layouts/Layout';
|
import Layout from './layouts/Layout';
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
import { PageRouting } from './data/RoutingData';
|
import { PageRouting } from './const/PageRouting';
|
||||||
|
import LoginPage from './ui/page/account/login/LoginPage';
|
||||||
|
import ResetPasswordPage from './ui/page/account/resetPassword/ResetPasswordPage';
|
||||||
|
import { HomePage } from './ui/page/home/HomePage';
|
||||||
|
import type { AuthData } from './data/AuthData';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { ScheduleMainPage } from './ui/page/schedule/ScheduleMainPage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { authData } = useAuthStore();
|
const { authData, login } = useAuthStore();
|
||||||
|
useEffect(() => {
|
||||||
|
const autoLogin = localStorage.getItem('autoLogin') === 'true';
|
||||||
|
if (autoLogin) {
|
||||||
|
const stored = localStorage.getItem('auth-storage');
|
||||||
|
if (stored) {
|
||||||
|
const storedAuthData = JSON.parse(stored).state as AuthData;
|
||||||
|
login(storedAuthData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route element={<SignUpPage />} path={PageRouting["SIGN_UP"].path} />
|
{
|
||||||
{!(authData?.isLogedIn) ? <Route element={<Navigate to={`/${PageRouting["SIGN_UP"].path}`} />} path="*" /> : null}
|
!authData
|
||||||
|
? <>
|
||||||
|
<Route element={<LoginPage />} path={PageRouting["LOGIN"].path} />
|
||||||
|
<Route element={<SignUpPage />} path={PageRouting["SIGN_UP"].path} />
|
||||||
|
<Route element={<ResetPasswordPage />} path={PageRouting["RESET_PASSWORD"].path} />
|
||||||
|
<Route element={<Navigate to={PageRouting["LOGIN"].path} />} path="*" />
|
||||||
|
</>
|
||||||
|
: <>
|
||||||
|
<Route element={<Navigate to={PageRouting["HOME"].path} />} path="*" />
|
||||||
|
<Route element={<HomePage />} path={PageRouting["HOME"].path} />
|
||||||
|
<Route element={<ScheduleMainPage />} path={PageRouting["SCHEDULES"].path} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
function Calendar({
|
function Calendar({
|
||||||
className,
|
className,
|
||||||
@@ -36,8 +37,28 @@ function Calendar({
|
|||||||
)}
|
)}
|
||||||
captionLayout={captionLayout}
|
captionLayout={captionLayout}
|
||||||
formatters={{
|
formatters={{
|
||||||
|
formatCaption: (month) => format(month, "yyyy년 MM월"),
|
||||||
|
formatWeekdayName: (weekday) => {
|
||||||
|
switch(weekday.getDay()) {
|
||||||
|
case 0:
|
||||||
|
return '일';
|
||||||
|
case 1:
|
||||||
|
return '월';
|
||||||
|
case 2:
|
||||||
|
return '화';
|
||||||
|
case 3:
|
||||||
|
return '수';
|
||||||
|
case 4:
|
||||||
|
return '목';
|
||||||
|
case 5:
|
||||||
|
return '금';
|
||||||
|
case 6:
|
||||||
|
return '토';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
formatMonthDropdown: (date) =>
|
formatMonthDropdown: (date) =>
|
||||||
date.toLocaleString("default", { month: "short" }),
|
date.toLocaleString("", { month: "short" }),
|
||||||
...formatters,
|
...formatters,
|
||||||
}}
|
}}
|
||||||
classNames={{
|
classNames={{
|
||||||
|
|||||||
@@ -89,9 +89,7 @@ function SidebarProvider({
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Helper to toggle the sidebar.
|
// Helper to toggle the sidebar.
|
||||||
const toggleSidebar = React.useCallback(() => {
|
const toggleSidebar = () => setOpen((open) => !open);
|
||||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
|
||||||
}, [isMobile, setOpen, setOpenMobile])
|
|
||||||
|
|
||||||
// Adds a keyboard shortcut to toggle the sidebar.
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -157,13 +155,15 @@ function Sidebar({
|
|||||||
collapsible = "offcanvas",
|
collapsible = "offcanvas",
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
forceSheet = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
side?: "left" | "right"
|
side?: "left" | "right"
|
||||||
variant?: "sidebar" | "floating" | "inset"
|
variant?: "sidebar" | "floating" | "inset"
|
||||||
collapsible?: "offcanvas" | "icon" | "none"
|
collapsible?: "offcanvas" | "icon" | "none",
|
||||||
|
forceSheet?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
const { isMobile, state, open, setOpen } = useSidebar()
|
||||||
|
|
||||||
if (collapsible === "none") {
|
if (collapsible === "none") {
|
||||||
return (
|
return (
|
||||||
@@ -180,14 +180,14 @@ function Sidebar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile || forceSheet) {
|
||||||
return (
|
return (
|
||||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
<Sheet open={open} onOpenChange={setOpen} {...props}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
data-sidebar="sidebar"
|
data-sidebar="sidebar"
|
||||||
data-slot="sidebar"
|
data-slot="sidebar"
|
||||||
data-mobile="true"
|
data-mobile="true"
|
||||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
className="bg-sidebar text-sidebar-foreground rounded-br-2xl rounded-tr-2xl w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
|
|||||||
408
src/components/ui/stepper.tsx
Normal file
408
src/components/ui/stepper.tsx
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
type StepperOrientation = 'horizontal' | 'vertical';
|
||||||
|
type StepState = 'active' | 'completed' | 'inactive' | 'loading';
|
||||||
|
type StepIndicators = {
|
||||||
|
active?: React.ReactNode;
|
||||||
|
completed?: React.ReactNode;
|
||||||
|
inactive?: React.ReactNode;
|
||||||
|
loading?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StepperContextValue {
|
||||||
|
activeStep: number;
|
||||||
|
setActiveStep: (step: number) => void;
|
||||||
|
stepsCount: number;
|
||||||
|
orientation: StepperOrientation;
|
||||||
|
registerTrigger: (node: HTMLButtonElement | null) => void;
|
||||||
|
triggerNodes: HTMLButtonElement[];
|
||||||
|
focusNext: (currentIdx: number) => void;
|
||||||
|
focusPrev: (currentIdx: number) => void;
|
||||||
|
focusFirst: () => void;
|
||||||
|
focusLast: () => void;
|
||||||
|
indicators: StepIndicators;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepItemContextValue {
|
||||||
|
step: number;
|
||||||
|
state: StepState;
|
||||||
|
isDisabled: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepperContext = createContext<StepperContextValue | undefined>(undefined);
|
||||||
|
const StepItemContext = createContext<StepItemContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
function useStepper() {
|
||||||
|
const ctx = useContext(StepperContext);
|
||||||
|
if (!ctx) throw new Error('useStepper must be used within a Stepper');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useStepItem() {
|
||||||
|
const ctx = useContext(StepItemContext);
|
||||||
|
if (!ctx) throw new Error('useStepItem must be used within a StepperItem');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepperProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
defaultValue?: number;
|
||||||
|
value?: number;
|
||||||
|
onValueChange?: (value: number) => void;
|
||||||
|
orientation?: StepperOrientation;
|
||||||
|
indicators?: StepIndicators;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stepper({
|
||||||
|
defaultValue = 1,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
indicators = {},
|
||||||
|
...props
|
||||||
|
}: StepperProps) {
|
||||||
|
const [activeStep, setActiveStep] = React.useState(defaultValue);
|
||||||
|
const [triggerNodes, setTriggerNodes] = React.useState<HTMLButtonElement[]>([]);
|
||||||
|
|
||||||
|
// Register/unregister triggers
|
||||||
|
const registerTrigger = React.useCallback((node: HTMLButtonElement | null) => {
|
||||||
|
setTriggerNodes((prev) => {
|
||||||
|
if (node && !prev.includes(node)) {
|
||||||
|
return [...prev, node];
|
||||||
|
} else if (!node && prev.includes(node!)) {
|
||||||
|
return prev.filter((n) => n !== node);
|
||||||
|
} else {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetActiveStep = React.useCallback(
|
||||||
|
(step: number) => {
|
||||||
|
if (value === undefined) {
|
||||||
|
setActiveStep(step);
|
||||||
|
}
|
||||||
|
onValueChange?.(step);
|
||||||
|
},
|
||||||
|
[value, onValueChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentStep = value ?? activeStep;
|
||||||
|
|
||||||
|
// Keyboard navigation logic
|
||||||
|
const focusTrigger = (idx: number) => {
|
||||||
|
if (triggerNodes[idx]) triggerNodes[idx].focus();
|
||||||
|
};
|
||||||
|
const focusNext = (currentIdx: number) => focusTrigger((currentIdx + 1) % triggerNodes.length);
|
||||||
|
const focusPrev = (currentIdx: number) => focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length);
|
||||||
|
const focusFirst = () => focusTrigger(0);
|
||||||
|
const focusLast = () => focusTrigger(triggerNodes.length - 1);
|
||||||
|
|
||||||
|
// Context value
|
||||||
|
const contextValue = React.useMemo<StepperContextValue>(
|
||||||
|
() => ({
|
||||||
|
activeStep: currentStep,
|
||||||
|
setActiveStep: handleSetActiveStep,
|
||||||
|
stepsCount: React.Children.toArray(children).filter(
|
||||||
|
(child): child is React.ReactElement =>
|
||||||
|
React.isValidElement(child) && (child.type as { displayName?: string }).displayName === 'StepperItem',
|
||||||
|
).length,
|
||||||
|
orientation,
|
||||||
|
registerTrigger,
|
||||||
|
focusNext,
|
||||||
|
focusPrev,
|
||||||
|
focusFirst,
|
||||||
|
focusLast,
|
||||||
|
triggerNodes,
|
||||||
|
indicators,
|
||||||
|
}),
|
||||||
|
[currentStep, handleSetActiveStep, children, orientation, registerTrigger, triggerNodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepperContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-orientation={orientation}
|
||||||
|
data-slot="stepper"
|
||||||
|
className={cn('w-full', className)}
|
||||||
|
data-orientation={orientation}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</StepperContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
step: number;
|
||||||
|
completed?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperItem({
|
||||||
|
step,
|
||||||
|
completed = false,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: StepperItemProps) {
|
||||||
|
const { activeStep } = useStepper();
|
||||||
|
|
||||||
|
const state: StepState = completed || step < activeStep ? 'completed' : activeStep === step ? 'active' : 'inactive';
|
||||||
|
|
||||||
|
const isLoading = loading && step === activeStep;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepItemContext.Provider value={{ step, state, isDisabled: disabled, isLoading }}>
|
||||||
|
<div
|
||||||
|
data-slot="stepper-item"
|
||||||
|
className={cn(
|
||||||
|
'group/step flex items-center justify-center group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col not-last:flex-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-state={state}
|
||||||
|
{...(isLoading ? { 'data-loading': true } : {})}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</StepItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepperTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperTrigger({ asChild = false, className, children, tabIndex, ...props }: StepperTriggerProps) {
|
||||||
|
const { state, isLoading } = useStepItem();
|
||||||
|
const stepperCtx = useStepper();
|
||||||
|
const { setActiveStep, activeStep, registerTrigger, triggerNodes, focusNext, focusPrev, focusFirst, focusLast } =
|
||||||
|
stepperCtx;
|
||||||
|
const { step, isDisabled } = useStepItem();
|
||||||
|
const isSelected = activeStep === step;
|
||||||
|
const id = `stepper-tab-${step}`;
|
||||||
|
const panelId = `stepper-panel-${step}`;
|
||||||
|
|
||||||
|
// Register this trigger for keyboard navigation
|
||||||
|
const btnRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (btnRef.current) {
|
||||||
|
registerTrigger(btnRef.current);
|
||||||
|
}
|
||||||
|
}, [btnRef.current]);
|
||||||
|
|
||||||
|
// Find our index among triggers for navigation
|
||||||
|
const myIdx = React.useMemo(
|
||||||
|
() => triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
|
||||||
|
[triggerNodes, btnRef.current],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
if (myIdx !== -1 && focusNext) focusNext(myIdx);
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
if (myIdx !== -1 && focusPrev) focusPrev(myIdx);
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
e.preventDefault();
|
||||||
|
if (focusFirst) focusFirst();
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
e.preventDefault();
|
||||||
|
if (focusLast) focusLast();
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveStep(step);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (asChild) {
|
||||||
|
return (
|
||||||
|
<span data-slot="stepper-trigger" data-state={state} className={className}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={btnRef}
|
||||||
|
role="tab"
|
||||||
|
id={id}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
aria-controls={panelId}
|
||||||
|
tabIndex={typeof tabIndex === 'number' ? tabIndex : isSelected ? 0 : -1}
|
||||||
|
data-slot="stepper-trigger"
|
||||||
|
data-state={state}
|
||||||
|
data-loading={isLoading}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 inline-flex items-center gap-3 rounded-full outline-none focus-visible:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-60',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveStep(step)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isDisabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperIndicator({ children, className }: React.ComponentProps<'div'>) {
|
||||||
|
const { state, isLoading } = useStepItem();
|
||||||
|
const { indicators } = useStepper();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="stepper-indicator"
|
||||||
|
data-state={state}
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center overflow-hidden justify-center size-6 shrink-0 border-background rounded-full text-xs',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="absolute">
|
||||||
|
{indicators &&
|
||||||
|
((isLoading && indicators.loading) ||
|
||||||
|
(state === 'completed' && indicators.completed) ||
|
||||||
|
(state === 'active' && indicators.active) ||
|
||||||
|
(state === 'inactive' && indicators.inactive))
|
||||||
|
? (isLoading && indicators.loading) ||
|
||||||
|
(state === 'completed' && indicators.completed) ||
|
||||||
|
(state === 'active' && indicators.active) ||
|
||||||
|
(state === 'inactive' && indicators.inactive)
|
||||||
|
: children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperSeparator({ className }: React.ComponentProps<'div'>) {
|
||||||
|
const { state } = useStepItem();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="stepper-separator"
|
||||||
|
data-state={state}
|
||||||
|
className={cn(
|
||||||
|
'm-0.5 rounded-full bg-muted group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5 group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperTitle({ children, className }: React.ComponentProps<'h3'>) {
|
||||||
|
const { state } = useStepItem();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<h3 data-slot="stepper-title" data-state={state} className={cn('text-sm font-medium leading-none', className)}>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperDescription({ children, className }: React.ComponentProps<'div'>) {
|
||||||
|
const { state } = useStepItem();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-slot="stepper-description" data-state={state} className={cn('text-sm text-muted-foreground', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperNav({ children, className }: React.ComponentProps<'nav'>) {
|
||||||
|
const { activeStep, orientation } = useStepper();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
data-slot="stepper-nav"
|
||||||
|
data-state={activeStep}
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperPanel({ children, className }: React.ComponentProps<'div'>) {
|
||||||
|
const { activeStep } = useStepper();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-slot="stepper-panel" data-state={activeStep} className={cn('w-full', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepperContentProps extends React.ComponentProps<'div'> {
|
||||||
|
value: number;
|
||||||
|
forceMount?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperContent({ value, forceMount, children, className }: StepperContentProps) {
|
||||||
|
const { activeStep } = useStepper();
|
||||||
|
const isActive = value === activeStep;
|
||||||
|
|
||||||
|
if (!forceMount && !isActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="stepper-content"
|
||||||
|
data-state={activeStep}
|
||||||
|
className={cn('w-full', className, !isActive && forceMount && 'hidden')}
|
||||||
|
hidden={!isActive && forceMount}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useStepper,
|
||||||
|
useStepItem,
|
||||||
|
Stepper,
|
||||||
|
StepperItem,
|
||||||
|
StepperTrigger,
|
||||||
|
StepperIndicator,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperDescription,
|
||||||
|
StepperPanel,
|
||||||
|
StepperContent,
|
||||||
|
StepperNav,
|
||||||
|
type StepperProps,
|
||||||
|
type StepperItemProps,
|
||||||
|
type StepperTriggerProps,
|
||||||
|
type StepperContentProps,
|
||||||
|
};
|
||||||
99
src/const/ColorPalette.ts
Normal file
99
src/const/ColorPalette.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
export type ColorPaletteType = {
|
||||||
|
index: number;
|
||||||
|
style: string;
|
||||||
|
main: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const ColorPalette: Record<any, ColorPaletteType> = {
|
||||||
|
Black: {
|
||||||
|
index: 0,
|
||||||
|
style: '#000000',
|
||||||
|
main: true
|
||||||
|
},
|
||||||
|
White: {
|
||||||
|
index: 1,
|
||||||
|
style: '#FFFFFF',
|
||||||
|
main: true
|
||||||
|
},
|
||||||
|
PeachCream: {
|
||||||
|
index: 2,
|
||||||
|
style: '#FFDAB9',
|
||||||
|
main: false
|
||||||
|
},
|
||||||
|
CoralPink: {
|
||||||
|
index: 3,
|
||||||
|
style: '#F08080',
|
||||||
|
main: true
|
||||||
|
},
|
||||||
|
MintIcing: {
|
||||||
|
index: 4,
|
||||||
|
style: '#C1E1C1',
|
||||||
|
main: false
|
||||||
|
},
|
||||||
|
Vanilla: {
|
||||||
|
index: 5,
|
||||||
|
style: '#FFFACD',
|
||||||
|
main: true
|
||||||
|
},
|
||||||
|
Wheat: {
|
||||||
|
index: 6,
|
||||||
|
style: '#F5DEB3',
|
||||||
|
main: false
|
||||||
|
},
|
||||||
|
AliceBlue: {
|
||||||
|
index: 7,
|
||||||
|
style: '#F0F8FF',
|
||||||
|
main: true
|
||||||
|
},
|
||||||
|
Lavender: {
|
||||||
|
index: 8,
|
||||||
|
style: '#E6E6FA',
|
||||||
|
main: false
|
||||||
|
},
|
||||||
|
LightAqua: {
|
||||||
|
index: 9,
|
||||||
|
style: '#A8E6CF',
|
||||||
|
main: true
|
||||||
|
},
|
||||||
|
CloudWhite: {
|
||||||
|
index: 10,
|
||||||
|
style: '#F0F8FF',
|
||||||
|
main: false
|
||||||
|
},
|
||||||
|
LightGray: {
|
||||||
|
index: 11,
|
||||||
|
style: '#D3D3D3',
|
||||||
|
main: true
|
||||||
|
},
|
||||||
|
LightKhakki: {
|
||||||
|
index: 12,
|
||||||
|
style: '#F0F8E6',
|
||||||
|
main: false
|
||||||
|
},
|
||||||
|
DustyRose: {
|
||||||
|
index: 13,
|
||||||
|
style: '#D8BFD8',
|
||||||
|
main: true
|
||||||
|
},
|
||||||
|
CreamBeige: {
|
||||||
|
index: 14,
|
||||||
|
style: '#FAF0E6',
|
||||||
|
main: true,
|
||||||
|
},
|
||||||
|
Oatmeal: {
|
||||||
|
index: 15,
|
||||||
|
style: '#FDF5E6',
|
||||||
|
main: false
|
||||||
|
},
|
||||||
|
CharcoalLight: {
|
||||||
|
index: 16,
|
||||||
|
style: '#A9A9A9',
|
||||||
|
main: true
|
||||||
|
},
|
||||||
|
Custom: {
|
||||||
|
index: 17,
|
||||||
|
style: 'transparent',
|
||||||
|
main: false
|
||||||
|
},
|
||||||
|
}
|
||||||
9
src/const/HttpResponse.ts
Normal file
9
src/const/HttpResponse.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export const HttpResponse = {
|
||||||
|
"ACCESS_TOKEN_EXPIRED": "ACCESS TOKEN EXPIRED",
|
||||||
|
"REFRESH_TOKEN_EXPIRED": "REFRESH TOKEN EXPIRED",
|
||||||
|
"UNAUTHORIZED": "UNAUTHORIZED",
|
||||||
|
"OK": "OK",
|
||||||
|
"CREATED": "CREATED",
|
||||||
|
"BAD_REQUEST": "BAD REQUEST",
|
||||||
|
"INTERNAL_SERVER_ERROR": "INTERNAL SERVER ERROR"
|
||||||
|
} as const;
|
||||||
20
src/const/PageRouting.ts
Normal file
20
src/const/PageRouting.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
type PageRoutingInfo = {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageRouting: Record<string, PageRoutingInfo> = {
|
||||||
|
LOGIN: { path: "/login", title: "" },
|
||||||
|
SIGN_UP: { path: "/signup", title: "" },
|
||||||
|
RESET_PASSWORD: { path: "/reset-password", title: "" },
|
||||||
|
HOME: { path: "/home", title: "홈" },
|
||||||
|
SCHEDULES: { path: "/schedules", title: "일정" },
|
||||||
|
SCHEDULES_NEW: { path: "/schedules/new", title: "일정 생성" },
|
||||||
|
SCHEDULES_EDIT: { path: "/schedules/edit", title: "일정 수정" },
|
||||||
|
SCHEDULES_DETAIL: { path: "/schedules/detail", title: "일정 상세" },
|
||||||
|
USER_INFO: { path: "/info", title: "사용자 정보" },
|
||||||
|
USER_FOLLOWING: { path: "/info/following", title: "팔로잉 목록" },
|
||||||
|
USER_FOLLOWER: { path: "/info/follower", title: "팔로워 목록" },
|
||||||
|
SETTINGS: { path: "/settings", title: "설정" },
|
||||||
|
NOT_FOUND: { path: "/not-found", title: "존재하지 않는 페이지" },
|
||||||
|
} as const;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
export type AuthData = {
|
export type AuthData = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
isLogedIn: boolean;
|
|
||||||
}
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
type PageRoutingInfo = {
|
|
||||||
path: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PageRouting: Record<string, PageRoutingInfo> = {
|
|
||||||
LOGIN: { path: "login", title: "" },
|
|
||||||
SIGN_UP: { path: "signup", title: "" },
|
|
||||||
RESET_PASSWORD: { path: "reset-password", title: "" },
|
|
||||||
HOME: { path: "home", title: "홈" },
|
|
||||||
SCHEDULES: { path: "schedules", title: "일정" },
|
|
||||||
SCHEDULES_NEW: { path: "schedules/new", title: "일정 생성" },
|
|
||||||
SCHEDULES_EDIT: { path: "schedules/edit", title: "일정 수정" },
|
|
||||||
SCHEDULES_DETAIL: { path: "schedules/detail", title: "일정 상세" },
|
|
||||||
USER_INFO: { path: "info", title: "사용자 정보" },
|
|
||||||
USER_FOLLOWING: { path: "info/following", title: "팔로잉 목록" },
|
|
||||||
USER_FOLLOWER: { path: "info/follower", title: "팔로워 목록" },
|
|
||||||
SETTINGS: { path: "settings", title: "설정" },
|
|
||||||
NOT_FOUD: { path: "not-found", title: "존재하지 않는 페이지" },
|
|
||||||
} as const;
|
|
||||||
@@ -3,7 +3,7 @@ import * as z from 'zod';
|
|||||||
export const EmailVerificationSchema = z.object({
|
export const EmailVerificationSchema = z.object({
|
||||||
email: z
|
email: z
|
||||||
.email()
|
.email()
|
||||||
, verificationCode: z
|
, code: z
|
||||||
.string()
|
.string()
|
||||||
.length(6, "이메일 인증 번호 6자리를 입력해주십시오.")
|
.length(6, "이메일 인증 번호 6자리를 입력해주십시오.")
|
||||||
});
|
});
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
|
import { Validator } from '@/util/Validator';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
export const LoginSchema = z.object({
|
export const LoginSchema = z.object({
|
||||||
email: z
|
id: z
|
||||||
.email()
|
.string()
|
||||||
|
.refine((val) => {
|
||||||
|
if (val.includes('@')) {
|
||||||
|
return Validator.isEmail(val);;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, {
|
||||||
|
message: "이메일 형식이 올바르지 않습니다."
|
||||||
|
})
|
||||||
, password: z
|
, password: z
|
||||||
.string()
|
.string()
|
||||||
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
||||||
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
||||||
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$]).*$/, "비밀번호는 영소문자로 시작하여 숫자, 특수문자(!@#$)를 한 개 이상 포함하여야 합니다.")
|
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "비밀번호는 영소문자로 시작하여 숫자, 특수문자(!@#$)를 한 개 이상 포함하여야 합니다.")
|
||||||
});
|
});
|
||||||
@@ -3,4 +3,19 @@ import * as z from 'zod';
|
|||||||
export const ResetPasswordSchema = z.object({
|
export const ResetPasswordSchema = z.object({
|
||||||
email: z
|
email: z
|
||||||
.email()
|
.email()
|
||||||
|
, code: z
|
||||||
|
.string()
|
||||||
|
.length(8)
|
||||||
|
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||||
|
, password: z
|
||||||
|
.string()
|
||||||
|
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
||||||
|
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
||||||
|
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||||
|
, passwordConfirm: z
|
||||||
|
.string()
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.passwordConfirm, {
|
||||||
|
path: ["passwordConfirm"],
|
||||||
|
error: "비밀번호가 일치하지 않습니다."
|
||||||
});
|
});
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
export const SignUpSchema = z.object({
|
export const SignUpSchema = z.object({
|
||||||
email: z
|
accountId: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "이메일을 입력해주십시오.")
|
.min(5, "아이디는 5 자리 이상이어야 합니다.")
|
||||||
|
.refine((val) => {
|
||||||
|
return /^[a-zA-z-_.]*$/.test(val);
|
||||||
|
}, {
|
||||||
|
message: "영문, 숫자, '- _ .' 를 제외한 문자를 사용할 수 없습니다."
|
||||||
|
})
|
||||||
|
, email: z
|
||||||
|
.string()
|
||||||
|
.min(5, "이메일을 입력해주십시오.")
|
||||||
, password: z
|
, password: z
|
||||||
.string()
|
.string()
|
||||||
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
.min(8, "비밀번호는 8-12 자리여야 합니다.")
|
||||||
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
.max(12, "비밀번호는 8-12 자리여야 합니다.")
|
||||||
.regex(/^[a-z](?=.*[0-9])(?=.*[!@#$]).*$/, "영문 소문자로 시작하고 숫자와 특수문자(!@#$)를 포함해야 합니다.")
|
.regex(/^(?=.*[0-9])(?=.*[!@#$%^])[a-zA-Z0-9!@#$%^]+$/, "영소문자로 시작하고 숫자와 특수문자(!@#$%^)를 포함해야 합니다.")
|
||||||
, name: z
|
, name: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "이름을 입력해주시십시오.")
|
.min(1, "이름을 입력해주시십시오.")
|
||||||
|
|||||||
9
src/data/request/account/CheckDuplicationRequest.ts
Normal file
9
src/data/request/account/CheckDuplicationRequest.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export class CheckDuplicationRequest {
|
||||||
|
type: 'email' | 'accountId';
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
constructor(type: 'email' | 'accountId', value: string) {
|
||||||
|
this.type = type;
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/data/request/account/LoginRequest.ts
Normal file
15
src/data/request/account/LoginRequest.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export class LoginRequest {
|
||||||
|
type: 'email' | 'accountId';
|
||||||
|
id: string;
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
type: 'email' | 'accountId',
|
||||||
|
id: string,
|
||||||
|
password: string
|
||||||
|
) {
|
||||||
|
this.type = type;
|
||||||
|
this.id = id;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/data/request/account/ResetPasswordRequest.ts
Normal file
4
src/data/request/account/ResetPasswordRequest.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export class ResetPasswordRequest {
|
||||||
|
email!: string;
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
3
src/data/request/account/SendResetPasswordCodeRequest.ts
Normal file
3
src/data/request/account/SendResetPasswordCodeRequest.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export class SendResetPasswordCodeRequest {
|
||||||
|
email!: string;
|
||||||
|
}
|
||||||
7
src/data/request/account/SendVerificationCodeRequest.ts
Normal file
7
src/data/request/account/SendVerificationCodeRequest.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export class SendVerificationCodeRequest {
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
constructor(email: string) {
|
||||||
|
this.email = email;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/data/request/account/SignupRequest.ts
Normal file
15
src/data/request/account/SignupRequest.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export class SignupRequest {
|
||||||
|
accountId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
nickname: string;
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
constructor(accountId: string, email: string, name: string, nickname: string, password: string) {
|
||||||
|
this.accountId = accountId;
|
||||||
|
this.email = email;
|
||||||
|
this.name = name;
|
||||||
|
this.nickname = nickname;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/data/request/account/VerifyCodeRequest.ts
Normal file
9
src/data/request/account/VerifyCodeRequest.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export class VerifyCodeRequest {
|
||||||
|
email: string;
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
constructor(email: string, code: string) {
|
||||||
|
this.email = email;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export class VerifyResetPasswordCodeRequest {
|
||||||
|
email!: string;
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
8
src/data/request/index.ts
Normal file
8
src/data/request/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export * from './account/CheckDuplicationRequest';
|
||||||
|
export * from './account/SendVerificationCodeRequest';
|
||||||
|
export * from './account/VerifyCodeRequest';
|
||||||
|
export * from './account/SignupRequest';
|
||||||
|
export * from './account/LoginRequest';
|
||||||
|
export * from './account/SendResetPasswordCodeRequest';
|
||||||
|
export * from './account/VerifyResetPasswordCodeRequest';
|
||||||
|
export * from './account/ResetPasswordRequest';
|
||||||
5
src/data/response/BaseResponse.ts
Normal file
5
src/data/response/BaseResponse.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export class BaseResponse {
|
||||||
|
success!: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
5
src/data/response/account/CheckDuplicationResponse.ts
Normal file
5
src/data/response/account/CheckDuplicationResponse.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
|
export class CheckDuplicationResponse extends BaseResponse{
|
||||||
|
isDuplicated!: boolean;
|
||||||
|
}
|
||||||
6
src/data/response/account/LoginResponse.ts
Normal file
6
src/data/response/account/LoginResponse.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
|
export class LoginResponse extends BaseResponse {
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
}
|
||||||
6
src/data/response/account/RefreshAccessTokenResponse.ts
Normal file
6
src/data/response/account/RefreshAccessTokenResponse.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
|
export class RefreshAccessTokenResponse extends BaseResponse {
|
||||||
|
accessToken!: string;
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
5
src/data/response/account/ResetPasswordResponse.ts
Normal file
5
src/data/response/account/ResetPasswordResponse.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
|
export class ResetPasswordResponse extends BaseResponse {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
|
export class SendResetPasswordCodeResponse extends BaseResponse {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
|
export class SendVerificationCodeResponse extends BaseResponse {
|
||||||
|
|
||||||
|
}
|
||||||
5
src/data/response/account/SignupResponse.ts
Normal file
5
src/data/response/account/SignupResponse.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
|
export class SignupResponse extends BaseResponse {
|
||||||
|
|
||||||
|
}
|
||||||
5
src/data/response/account/VerifyCodeResponse.ts
Normal file
5
src/data/response/account/VerifyCodeResponse.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
|
export class VerifyCodeResponse extends BaseResponse {
|
||||||
|
verified!: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { BaseResponse } from "../BaseResponse";
|
||||||
|
|
||||||
|
export class VerifyResetPasswordCodeResponse extends BaseResponse {
|
||||||
|
verified!: boolean;
|
||||||
|
}
|
||||||
8
src/data/response/index.ts
Normal file
8
src/data/response/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export * from './account/CheckDuplicationResponse';
|
||||||
|
export * from './account/SendVerificationCodeResponse';
|
||||||
|
export * from './account/VerifyCodeResponse';
|
||||||
|
export * from './account/SignupResponse';
|
||||||
|
export * from './account/LoginResponse';
|
||||||
|
export * from './account/SendResetPasswordCodeResponse';
|
||||||
|
export * from './account/VerifyResetPasswordCodeResponse';
|
||||||
|
export * from './account/ResetPasswordResponse';
|
||||||
67
src/hooks/use-palette.ts
Normal file
67
src/hooks/use-palette.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { ColorPalette, type ColorPaletteType } from "@/const/ColorPalette";
|
||||||
|
|
||||||
|
export function usePalette() {
|
||||||
|
const ColorPaletteType = typeof ColorPalette;
|
||||||
|
|
||||||
|
const getPaletteNameList = () => {
|
||||||
|
return Object.keys(ColorPalette);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMainPaletteList = () => {
|
||||||
|
const paletteKeys = Object.keys(ColorPalette);
|
||||||
|
let paletteList: ColorPaletteType[] = [];
|
||||||
|
paletteKeys.forEach((paletteKey) => {
|
||||||
|
const key = paletteKey as keyof typeof ColorPalette;
|
||||||
|
const palette: ColorPaletteType = ColorPalette[key];
|
||||||
|
if (palette.main) {
|
||||||
|
paletteList.push(palette);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
paletteList = paletteList.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
return paletteList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExtraPaletteList = () => {
|
||||||
|
const paletteKeys = Object.keys(ColorPalette);
|
||||||
|
let paletteList: ColorPaletteType[] = [];
|
||||||
|
paletteKeys.forEach((paletteKey) => {
|
||||||
|
const key = paletteKey as keyof typeof ColorPalette;
|
||||||
|
const palette: ColorPaletteType = ColorPalette[key];
|
||||||
|
if (!palette.main) {
|
||||||
|
paletteList.push(palette);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
paletteList = paletteList.sort((a, b) => a.index - b.index);
|
||||||
|
return paletteList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllPaletteList = [...getMainPaletteList(), ...getExtraPaletteList()].sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
const getPaletteByKey = (key: keyof typeof ColorPalette) => {
|
||||||
|
return ColorPalette[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCustomColor = (style: string) => {
|
||||||
|
return {
|
||||||
|
style: `#${style}`,
|
||||||
|
main: false
|
||||||
|
} as ColorPaletteType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyle = (palette: ColorPaletteType) => {
|
||||||
|
return palette.style;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ColorPaletteType,
|
||||||
|
getPaletteNameList,
|
||||||
|
getMainPaletteList,
|
||||||
|
getExtraPaletteList,
|
||||||
|
getAllPaletteList,
|
||||||
|
getPaletteByKey,
|
||||||
|
getCustomColor,
|
||||||
|
getStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/hooks/use-toast.ts
Normal file
1
src/hooks/use-toast.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import { toast } from 'sonner';
|
||||||
27
src/hooks/use-viewport.ts
Normal file
27
src/hooks/use-viewport.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const useViewport = () => {
|
||||||
|
const [width, setWidth] = useState(
|
||||||
|
typeof window !== 'undefined' ? window.innerWidth : 0
|
||||||
|
);
|
||||||
|
const [height, setHeight] = useState(
|
||||||
|
typeof window !== 'undefined' ? window.innerHeight : 0
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setWidth(window.innerWidth);
|
||||||
|
setHeight(window.innerHeight);
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useViewport;
|
||||||
@@ -120,7 +120,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
height: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 1280px;
|
||||||
|
min-height: 720px;
|
||||||
|
max-height: 1080px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chrome, Safari, Edge */
|
/* Chrome, Safari, Edge */
|
||||||
@@ -130,7 +134,19 @@ input[type="number"]::-webkit-outer-spin-button {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rdp-day {
|
||||||
|
aspect-ratio: unset;
|
||||||
|
}
|
||||||
|
|
||||||
/* Firefox */
|
/* Firefox */
|
||||||
input[type="number"] {
|
input[type="number"] {
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-week:not(:first-child) {
|
||||||
|
@apply border-t;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-day:not(:first-child) {
|
||||||
|
@apply border-l;
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,57 @@
|
|||||||
import SideBar from "@/ui/component/SideBar";
|
import SideBar from "@/ui/component/SideBar";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet, useNavigate } from "react-router-dom";
|
||||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import Header from "@/ui/component/Header";
|
import Header from "@/ui/component/Header";
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { Toaster, type ToasterProps } from "sonner";
|
||||||
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
OctagonXIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { authData } = useAuthStore();
|
const { authData } = useAuthStore();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const pathname = location.pathname;
|
||||||
|
|
||||||
|
const goTo = (path: string) => {
|
||||||
|
console.log(path);
|
||||||
|
console.log(pathname);
|
||||||
|
if (path === pathname) return;
|
||||||
|
navigate(path);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider
|
<>
|
||||||
defaultOpen={false}
|
<Toaster
|
||||||
id="root"
|
position="top-center"
|
||||||
>
|
icons={{
|
||||||
<SideBar />
|
success: <CircleCheckIcon className="size-4" fill="#15b815" color="white" />,
|
||||||
<div className="flex flex-col w-full h-full">
|
error: <OctagonXIcon className="size-4" fill="#f14e4e" color="white" />,
|
||||||
{ authData?.isLogedIn ? <Header /> : null}
|
info: <InfoIcon className="size-4" fill="black" color="white" />,
|
||||||
{/* <Header /> */}
|
warning: <TriangleAlertIcon className="size-4" fill="#ffd500" color="white" />,
|
||||||
<Outlet />
|
loading: <Loader2Icon className="size-4 animate-spin" fill="white" color="black" />
|
||||||
</div>
|
}}
|
||||||
</SidebarProvider>
|
/>
|
||||||
|
<SidebarProvider
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
id="root"
|
||||||
|
>
|
||||||
|
<SideBar goTo={goTo} />
|
||||||
|
<div className="flex flex-col w-full h-full">
|
||||||
|
{ authData ? <Header /> : null}
|
||||||
|
<div className="w-full h-full p-2.5">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import './index.css'
|
|||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
// <StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
// </StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
97
src/network/AccountNetwork.ts
Normal file
97
src/network/AccountNetwork.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
CheckDuplicationRequest,
|
||||||
|
SendVerificationCodeRequest,
|
||||||
|
VerifyCodeRequest,
|
||||||
|
SignupRequest,
|
||||||
|
LoginRequest,
|
||||||
|
SendResetPasswordCodeRequest,
|
||||||
|
VerifyResetPasswordCodeRequest,
|
||||||
|
ResetPasswordRequest
|
||||||
|
} from "@/data/request";
|
||||||
|
import {
|
||||||
|
CheckDuplicationResponse,
|
||||||
|
SendVerificationCodeResponse,
|
||||||
|
VerifyCodeResponse,
|
||||||
|
SignupResponse,
|
||||||
|
LoginResponse,
|
||||||
|
SendResetPasswordCodeResponse,
|
||||||
|
VerifyResetPasswordCodeResponse,
|
||||||
|
ResetPasswordResponse
|
||||||
|
} from "@/data/response";
|
||||||
|
import { BaseNetwork } from "./BaseNetwork";
|
||||||
|
|
||||||
|
export class AccountNetwork extends BaseNetwork {
|
||||||
|
private baseUrl = "/account";
|
||||||
|
|
||||||
|
async checkDuplication(data: CheckDuplicationRequest) {
|
||||||
|
const { type, value } = data;
|
||||||
|
|
||||||
|
return await this.get<CheckDuplicationResponse>(
|
||||||
|
`${this.baseUrl}/check-duplication?type=${type}&value=${value}`
|
||||||
|
, {
|
||||||
|
authPass: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendVerificationCode(data: SendVerificationCodeRequest) {
|
||||||
|
return await this.post<SendVerificationCodeResponse>(
|
||||||
|
this.baseUrl + "/send-verification-code"
|
||||||
|
, data
|
||||||
|
, {
|
||||||
|
authPass: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyCode(data: VerifyCodeRequest) {
|
||||||
|
return await this.post<VerifyCodeResponse>(
|
||||||
|
this.baseUrl + "/verify-code"
|
||||||
|
, data
|
||||||
|
, {
|
||||||
|
authPass: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async signup(data: SignupRequest) {
|
||||||
|
return await this.post<SignupResponse>(
|
||||||
|
this.baseUrl + "/signup"
|
||||||
|
, data
|
||||||
|
, {
|
||||||
|
authPass: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(data: LoginRequest) {
|
||||||
|
return await this.post<LoginResponse>(
|
||||||
|
this.baseUrl + "/login"
|
||||||
|
, data
|
||||||
|
, {
|
||||||
|
authPass: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendResetPasswordCode(data: SendResetPasswordCodeRequest) {
|
||||||
|
return await this.post<SendResetPasswordCodeResponse>(
|
||||||
|
this.baseUrl + '/send-reset-password-code',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyResetPasswordCode(data: VerifyResetPasswordCodeRequest) {
|
||||||
|
return await this.post<VerifyResetPasswordCodeResponse>(
|
||||||
|
this.baseUrl + '/verify-reset-password-code',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(data: ResetPasswordRequest) {
|
||||||
|
return await this.post<ResetPasswordResponse>(
|
||||||
|
this.baseUrl + '/reset-password',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/network/BaseNetwork.ts
Normal file
168
src/network/BaseNetwork.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type {
|
||||||
|
AxiosInstance,
|
||||||
|
AxiosRequestConfig,
|
||||||
|
AxiosError,
|
||||||
|
AxiosResponse,
|
||||||
|
InternalAxiosRequestConfig,
|
||||||
|
} from "axios";
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { RefreshAccessTokenResponse } from '@/data/response/account/RefreshAccessTokenResponse';
|
||||||
|
import type { AuthData } from '@/data/AuthData';
|
||||||
|
|
||||||
|
export class BaseNetwork {
|
||||||
|
protected instance: AxiosInstance;
|
||||||
|
|
||||||
|
private isRefreshing = false;
|
||||||
|
private refreshQueue: Array<(token:string) => void> = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.instance = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL || "http://localhost:3000",
|
||||||
|
timeout: 10_000,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setInterceptors();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청/응답 인터셉터 설정
|
||||||
|
*/
|
||||||
|
protected setInterceptors() {
|
||||||
|
// ★ 요청 인터셉터
|
||||||
|
this.instance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const reqConfig = config as InternalAxiosRequestConfig & { authPass?: boolean };
|
||||||
|
if (reqConfig.authPass) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
const accessToken = localStorage.getItem("accessToken");
|
||||||
|
if (accessToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ★ 응답 인터셉터
|
||||||
|
this.instance.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => response,
|
||||||
|
(error: AxiosError) => {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const errorCode = (error.response?.data as any)?.code;
|
||||||
|
const originalRequest = error.config as AxiosRequestConfig & {
|
||||||
|
_retry?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
status === 401
|
||||||
|
&& errorCode === 'AccessTokenExpired'
|
||||||
|
&& !originalRequest._retry
|
||||||
|
) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
return this.handleRefreshToken(originalRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRefreshToken(originalRequest: AxiosRequestConfig) {
|
||||||
|
|
||||||
|
const authData = useAuthStore.getState().authData;
|
||||||
|
const refreshToken = authData?.refreshToken;
|
||||||
|
|
||||||
|
if (!authData || !refreshToken) {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
return Promise.reject("no refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isRefreshing) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.refreshQueue.push((newToken: string) => {
|
||||||
|
originalRequest.headers = {
|
||||||
|
...originalRequest.headers,
|
||||||
|
Authorization: `Bearer ${newToken}`
|
||||||
|
};
|
||||||
|
resolve(this.instance(originalRequest));
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.refreshToken();
|
||||||
|
|
||||||
|
const newAccessToken = useAuthStore.getState().authData!.accessToken;
|
||||||
|
|
||||||
|
this.refreshQueue.forEach((cb) => cb(newAccessToken));
|
||||||
|
this.refreshQueue = [];
|
||||||
|
originalRequest.headers = {
|
||||||
|
...originalRequest.headers,
|
||||||
|
Authorization: `Bearer ${newAccessToken}`,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
return this.instance(originalRequest);
|
||||||
|
} catch (err) {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
window.location.href = '/login?expired=1'
|
||||||
|
return Promise.reject(err);
|
||||||
|
} finally {
|
||||||
|
this.isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 CRUD 메서드
|
||||||
|
*/
|
||||||
|
protected async get<T = any>(url: string, config?: AxiosRequestConfig & { authPass?: boolean }) {
|
||||||
|
return await this.instance.get<T>(url, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig & { authPass?: boolean }) {
|
||||||
|
return await this.instance.post<T>(url, data, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refreshToken() {
|
||||||
|
const storedAuth = localStorage.getItem('auth-storage');
|
||||||
|
|
||||||
|
if (!storedAuth) {
|
||||||
|
localStorage.setItem('autoLogin', 'false');
|
||||||
|
throw new Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authData: AuthData = JSON.parse(storedAuth).state;
|
||||||
|
|
||||||
|
if (!authData || !authData.refreshToken) {
|
||||||
|
localStorage.setItem('autoLogin', 'false');
|
||||||
|
throw new Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.get<RefreshAccessTokenResponse>(
|
||||||
|
'/account/refresh-access-token',
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${authData.refreshToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.data.success) throw new Error;
|
||||||
|
|
||||||
|
const newAccessToken = result.data.accessToken;
|
||||||
|
const newRefreshToken = result.data.refreshToken;
|
||||||
|
|
||||||
|
useAuthStore.getState().login({
|
||||||
|
accessToken: newAccessToken,
|
||||||
|
refreshToken: newRefreshToken
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
import type { AuthData } from '@/data/AuthData';
|
import type { AuthData } from '@/data/AuthData';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||||
|
|
||||||
interface AuthStoreProps {
|
interface AuthStoreProps {
|
||||||
authData: AuthData | undefined;
|
authData: AuthData | undefined;
|
||||||
login: (data: AuthData) => void;
|
login: (data: AuthData) => void;
|
||||||
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthStoreProps>((set) => ({
|
const storage = sessionStorage;
|
||||||
authData: undefined,
|
export const useAuthStore = create<AuthStoreProps>()(
|
||||||
login: (data: AuthData) => set({ authData: data }),
|
persist(
|
||||||
logout: () => set({ authData: undefined })
|
(set) => ({
|
||||||
}));
|
authData: undefined,
|
||||||
|
login: (data: AuthData) => {
|
||||||
|
set({ authData: data });
|
||||||
|
},
|
||||||
|
logout: () => {
|
||||||
|
localStorage.setItem('autoLogin', 'false');
|
||||||
|
localStorage.removeItem('auth-storage');
|
||||||
|
set({ authData: undefined });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
storage: createJSONStorage(() => storage)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -2,13 +2,53 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { LogOutIcon } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PageRouting } from '@/const/PageRouting';
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { logout } = useAuthStore();
|
||||||
|
|
||||||
|
const handleClickLogoutButton = () => {
|
||||||
|
logout();
|
||||||
|
navigate(PageRouting["LOGIN"].path);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex shrink-0 items-center gap-2 border-b px-4 w-full h-12">
|
<header className="w-full flex shrink-0 flex-row justify-between items-center border-b px-4 h-12">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Label>{import.meta.env.BASE_URL}</Label>
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
|
<Label>{import.meta.env.BASE_URL}</Label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
className={`
|
||||||
|
group flex items-center justify-start
|
||||||
|
pr-2 pl-2 border border-red-500 bg-white
|
||||||
|
transition-all duration-150
|
||||||
|
w-10 hover:w-25 hover:bg-red-500
|
||||||
|
overflow-hidden rounded-md
|
||||||
|
`}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClickLogoutButton}
|
||||||
|
>
|
||||||
|
<LogOutIcon
|
||||||
|
className="text-red-500 transition-colors duration-150 group-hover:text-white"
|
||||||
|
/>
|
||||||
|
<span className="
|
||||||
|
text-red-500 group-hover:text-white
|
||||||
|
opacity-0 scale-1
|
||||||
|
transition-all duration-150
|
||||||
|
group-hover:opacity-100 group-hover:scale-100
|
||||||
|
">
|
||||||
|
로그아웃
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,20 @@ import {
|
|||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
SidebarHeader
|
SidebarHeader
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
|
import { PageRouting } from '@/const/PageRouting';
|
||||||
|
|
||||||
|
interface SideBarProps {
|
||||||
|
goTo: (path: string) => void;
|
||||||
|
}
|
||||||
|
export default function SideBar({ goTo } : SideBarProps) {
|
||||||
|
|
||||||
export default function SideBar() {
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar forceSheet={true}>
|
||||||
|
<SidebarHeader></SidebarHeader>
|
||||||
|
<SidebarContent className="flex flex-col p-4 cursor-default">
|
||||||
|
<div onClick={() => goTo(PageRouting["HOME"].path)}>Home</div>
|
||||||
|
<div onClick={() => goTo(PageRouting["SCHEDULES"].path)}>Schedules</div>
|
||||||
|
</SidebarContent>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
190
src/ui/component/calendar/CustomCalendar.tsx
Normal file
190
src/ui/component/calendar/CustomCalendar.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { getDefaultClassNames } from "react-day-picker";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { isSameDay, getWeeksInMonth, getWeekOfMonth } from "date-fns";
|
||||||
|
import { SchedulePopover } from "../popover/SchedulePopover";
|
||||||
|
|
||||||
|
interface CustomCalendarProps {
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomCalendar = ({ data }: CustomCalendarProps) => {
|
||||||
|
const [weekCount, setWeekCount] = useState(5);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
||||||
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
|
const [popoverSide, setPopoverSide] = useState<'right' | 'left'>('right');
|
||||||
|
const [popoverAlign, setPopoverAlign] = useState<'start' | 'end'>('end');
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const updateWeekCount = () => {
|
||||||
|
if (containerRef === null) return;
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const weeks = containerRef.current.querySelectorAll('.rdp-week');
|
||||||
|
|
||||||
|
if (weeks?.length) setWeekCount(weeks.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
updateWeekCount();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setPopoverOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setSelectedDate(undefined);
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDaySelect = (date: Date | undefined) => {
|
||||||
|
if (!date) {
|
||||||
|
setPopoverOpen(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setSelectedDate(undefined);
|
||||||
|
}, 150);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (date) {
|
||||||
|
setSelectedDate(date);
|
||||||
|
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
|
||||||
|
if (0 <= dayOfWeek && dayOfWeek < 4) {
|
||||||
|
setPopoverSide('right');
|
||||||
|
} else {
|
||||||
|
setPopoverSide('left');
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = { weekStartsOn: 0 as 0 };
|
||||||
|
|
||||||
|
const totalWeeks = getWeeksInMonth(date, options);
|
||||||
|
|
||||||
|
const currentWeekNumber = getWeekOfMonth(date, options);
|
||||||
|
|
||||||
|
const threshold = Math.ceil(totalWeeks / 2);
|
||||||
|
|
||||||
|
if (currentWeekNumber <= threshold) {
|
||||||
|
setPopoverAlign('start');
|
||||||
|
} else {
|
||||||
|
setPopoverAlign('end');
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setPopoverOpen(true);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full h-full"
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
<Popover
|
||||||
|
open={popoverOpen}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
className="h-full w-full border rounded-lg"
|
||||||
|
selected={selectedDate}
|
||||||
|
onSelect={handleDaySelect}
|
||||||
|
onMonthChange={() => {
|
||||||
|
// month 바뀐 직후 DOM 변화가 생기므로 다음 프레임에서 계산
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateWeekCount();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
months: cn(
|
||||||
|
defaultClassNames.months,
|
||||||
|
"w-full h-full relative"
|
||||||
|
),
|
||||||
|
nav: cn(
|
||||||
|
defaultClassNames.nav,
|
||||||
|
"flex w-full item-center gap-1 justify-around absolute top-0 inset-x-0"
|
||||||
|
),
|
||||||
|
month: cn(
|
||||||
|
defaultClassNames.month,
|
||||||
|
"h-full w-full flex flex-col"
|
||||||
|
),
|
||||||
|
month_grid: cn(
|
||||||
|
defaultClassNames.month_grid,
|
||||||
|
"w-full h-full flex-1"
|
||||||
|
),
|
||||||
|
weeks: cn(
|
||||||
|
defaultClassNames.weeks,
|
||||||
|
"w-full h-full"
|
||||||
|
),
|
||||||
|
weekdays: cn(
|
||||||
|
defaultClassNames.weekdays,
|
||||||
|
"w-full"
|
||||||
|
),
|
||||||
|
week: cn(
|
||||||
|
defaultClassNames.week,
|
||||||
|
`w-full`
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
defaultClassNames.day,
|
||||||
|
`w-[calc(100%/7)] rounded-none`
|
||||||
|
),
|
||||||
|
day_button: cn(
|
||||||
|
defaultClassNames.day_button,
|
||||||
|
"h-full w-full flex p-2 justify-start items-start",
|
||||||
|
"hover:bg-transparent",
|
||||||
|
"data-[selected-single=true]:bg-transparent data-[selected-single=true]:text-black"
|
||||||
|
),
|
||||||
|
selected: cn(
|
||||||
|
defaultClassNames.selected,
|
||||||
|
"h-full border-0 fill-transparent"
|
||||||
|
),
|
||||||
|
today: cn(
|
||||||
|
defaultClassNames.today,
|
||||||
|
"h-full"
|
||||||
|
),
|
||||||
|
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
day: {
|
||||||
|
height: `calc(100%/${weekCount})`
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Day: ({ day, ...props }) => {
|
||||||
|
const date = day.date;
|
||||||
|
const isSelected = selectedDate && isSameDay(selectedDate, date);
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
{ isSelected
|
||||||
|
? <PopoverTrigger asChild>
|
||||||
|
{props.children}
|
||||||
|
</PopoverTrigger>
|
||||||
|
: props.children
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: ({ day, ...props}) => (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
disabled={day.outside}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SchedulePopover
|
||||||
|
date={selectedDate}
|
||||||
|
popoverSide={popoverSide}
|
||||||
|
popoverAlign={popoverAlign}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,98 +1,156 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { useState, type ReactNode, useEffect } from "react";
|
||||||
import { useModal } from "@/hooks/useModal";
|
|
||||||
import { useState, type ReactNode } from "react";
|
|
||||||
import { EmailVerificationSchema } from "@/data/form";
|
import { EmailVerificationSchema } from "@/data/form";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||||
|
import { AccountNetwork } from "@/network/AccountNetwork";
|
||||||
|
import { SendVerificationCodeRequest } from "@/data/request";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DialogHeader, Dialog, DialogTrigger, DialogContent, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
|
||||||
type EmailVerificationModalProps = {
|
type EmailVerificationModalProps = {
|
||||||
trigger: ReactNode;
|
trigger: ReactNode;
|
||||||
email: string;
|
email: string;
|
||||||
handler: () => void;
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
onVerifySuccess: () => Promise<any>;
|
||||||
}
|
}
|
||||||
export default function EmailVerificationModal(
|
export default function EmailVerificationModal({
|
||||||
{ trigger, email, handler } : EmailVerificationModalProps
|
trigger,
|
||||||
) {
|
email,
|
||||||
const [isVerifying, setIsVerifying] = useState<boolean>(false);
|
open,
|
||||||
const { open, close, Modal } = useModal();
|
setOpen,
|
||||||
|
onVerifySuccess
|
||||||
|
}: EmailVerificationModalProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const accountNetwork = new AccountNetwork();
|
||||||
|
|
||||||
const emailVerificationForm = useForm<z.infer<typeof EmailVerificationSchema>>({
|
const emailVerificationForm = useForm<z.infer<typeof EmailVerificationSchema>>({
|
||||||
resolver: zodResolver(EmailVerificationSchema),
|
resolver: zodResolver(EmailVerificationSchema),
|
||||||
defaultValues: {
|
mode: "onSubmit",
|
||||||
email: email,
|
defaultValues: { email: "", code: "" }
|
||||||
verificationCode: ""
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOnOpenChange = (isOpen: boolean) => {
|
useEffect(() => {
|
||||||
if (!isVerifying) {
|
if (open) {
|
||||||
if (isOpen) {
|
init();
|
||||||
open();
|
} else {
|
||||||
|
emailVerificationForm.setValue("code", "");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
const data = new SendVerificationCodeRequest(email);
|
||||||
|
const result = await accountNetwork.sendVerificationCode(data);
|
||||||
|
if (!result.data.success) {
|
||||||
|
openErrorToast();
|
||||||
} else {
|
} else {
|
||||||
emailVerificationForm.clearErrors();
|
emailVerificationForm.setValue("email", email);
|
||||||
close();
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
openErrorToast();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const verifyCode = () => {
|
const openErrorToast = () => {
|
||||||
console.log(emailVerificationForm.getValues("verificationCode"));
|
toast.error("이메일 인증 코드 발송에 실패하였습니다.", {
|
||||||
|
duration: 3000,
|
||||||
|
onDismiss: () => setOpen(false)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOnOTPComplete = (otp: string) => {
|
const onSubmit = async () => {
|
||||||
setIsVerifying(true);
|
if (isLoading) return;
|
||||||
emailVerificationForm.setValue("verificationCode", otp, { shouldValidate: true });
|
|
||||||
emailVerificationForm.handleSubmit(verifyCode)();
|
const { email, code } = emailVerificationForm.getValues();
|
||||||
|
|
||||||
|
const verifyCodePromise = accountNetwork.verifyCode({ email, code });
|
||||||
|
|
||||||
|
toast.promise(
|
||||||
|
verifyCodePromise,
|
||||||
|
{
|
||||||
|
loading: "이메일 인증 확인 중입니다.",
|
||||||
|
success: (res) => res.data.verified ? "이메일 인증이 완료되었습니다." : "잘못된 인증 코드입니다.",
|
||||||
|
error: "이메일 인증에 실패하였습니다.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
verifyCodePromise.then((res) => {
|
||||||
|
if (res.data.verified) {
|
||||||
|
onVerifySuccess();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (<Modal
|
const handleOnOTPComplete = async (otp: string) => {
|
||||||
onOpenChange={handleOnOpenChange}
|
if (otp.length < 6) return;
|
||||||
children={
|
setIsLoading(true);
|
||||||
<form id="form-email-verification" onSubmit={verifyCode}>
|
emailVerificationForm.setValue("code", otp, { shouldValidate: true });
|
||||||
<FieldGroup>
|
const isValid = await emailVerificationForm.trigger("code");
|
||||||
<Controller
|
if (isValid) await onSubmit();
|
||||||
name="verificationCode"
|
setIsLoading(false);
|
||||||
control={emailVerificationForm.control}
|
}
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field
|
return (
|
||||||
data-invalid={fieldState.invalid}
|
<Dialog open={open} onOpenChange={setOpen} modal>
|
||||||
className="w-full flex flex-col justify-center items-center"
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
>
|
<DialogContent>
|
||||||
<FieldLabel htmlFor="form-email-verification-code">
|
<DialogHeader>
|
||||||
인증 번호
|
<DialogTitle>이메일 인증</DialogTitle>
|
||||||
</FieldLabel>
|
</DialogHeader>
|
||||||
<div
|
|
||||||
className="flex flex-row justify-center items-center"
|
<form id="form-email-verification" onSubmit={emailVerificationForm.handleSubmit(onSubmit)}>
|
||||||
|
<FieldGroup>
|
||||||
|
<Controller
|
||||||
|
name="code"
|
||||||
|
control={emailVerificationForm.control}
|
||||||
|
render={
|
||||||
|
({ field, fieldState }) => (
|
||||||
|
<Field
|
||||||
|
data-invalid={fieldState.invalid}
|
||||||
|
className="w-full flex flex-col justify-center items-center"
|
||||||
>
|
>
|
||||||
<InputOTP
|
<FieldLabel htmlFor="form-email-verification-code">
|
||||||
maxLength={6}
|
인증 번호
|
||||||
inputMode="numeric"
|
</FieldLabel>
|
||||||
pattern="\d*"
|
<div className="flex flex-row justify-center items-center">
|
||||||
id="form-email-verification-code"
|
<InputOTP
|
||||||
onComplete={handleOnOTPComplete}
|
maxLength={6}
|
||||||
required>
|
inputMode="numeric"
|
||||||
<InputOTPGroup className="gap-2.5 *:data-[slot=input-otp-slot]:rounded-md *:data-[slot=input-otp-slot]:border">
|
pattern="\d*"
|
||||||
<InputOTPSlot index={0} />
|
id="form-email-verification-code"
|
||||||
<InputOTPSlot index={1} />
|
onComplete={handleOnOTPComplete}
|
||||||
<InputOTPSlot index={2} />
|
value={field.value}
|
||||||
<InputOTPSlot index={3} />
|
onChange={(value) => field.onChange(value)}
|
||||||
<InputOTPSlot index={4} />
|
onBlur={field.onBlur}
|
||||||
<InputOTPSlot index={5} />
|
required
|
||||||
</InputOTPGroup>
|
>
|
||||||
</InputOTP>
|
<InputOTPGroup
|
||||||
</div>
|
className="gap-2.5 *:data-[slot=input-otp-slot]:rounded-md *:data-[slot=input-otp-slot]:border"
|
||||||
|
>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
<FieldError errors={[fieldState.error]} />
|
<FieldError errors={[fieldState.error]} />
|
||||||
</Field>
|
</Field> )}
|
||||||
)}
|
/>
|
||||||
/>
|
</FieldGroup>
|
||||||
</FieldGroup>
|
</form>
|
||||||
</form>
|
|
||||||
}
|
<DialogFooter>
|
||||||
trigger={trigger}
|
{/* 필요시 footer 버튼 */}
|
||||||
title="이메일 인증"
|
</DialogFooter>
|
||||||
footer={null}
|
</DialogContent>
|
||||||
/>);
|
</Dialog>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|||||||
72
src/ui/component/popover/ColorPickPopover.tsx
Normal file
72
src/ui/component/popover/ColorPickPopover.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { PopoverContent } from "@/components/ui/popover"
|
||||||
|
import type { ColorPaletteType } from "@/const/ColorPalette"
|
||||||
|
import { usePalette } from "@/hooks/use-palette";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface ColorPickPopoverProps {
|
||||||
|
setColor: (color: ColorPaletteType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColorPickPopover = ({ setColor }: ColorPickPopoverProps) => {
|
||||||
|
const [seeMore, setSeeMore] = useState(false);
|
||||||
|
const {
|
||||||
|
getMainPaletteList,
|
||||||
|
getExtraPaletteList,
|
||||||
|
getCustomColor
|
||||||
|
} = usePalette();
|
||||||
|
const mainPaletteList = getMainPaletteList();
|
||||||
|
const extraPaletteList = getExtraPaletteList();
|
||||||
|
|
||||||
|
const getSlicedList = (paletteList: ColorPaletteType[], length: number) => {
|
||||||
|
const slicedList: ColorPaletteType[][] = [];
|
||||||
|
let index = 0;
|
||||||
|
while (index < paletteList.length) {
|
||||||
|
slicedList.push(paletteList.slice(index, index + length));
|
||||||
|
index += length;
|
||||||
|
}
|
||||||
|
return slicedList;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverContent
|
||||||
|
className="flex flex-col gap-1.5 w-fit"
|
||||||
|
>
|
||||||
|
{getSlicedList(mainPaletteList, 5).map((list) => (
|
||||||
|
<div className="flex flex-row gap-2.5">
|
||||||
|
{list.map((palette) => (
|
||||||
|
<div
|
||||||
|
className="rounded-full w-5 h-5 border border-gray-300"
|
||||||
|
style={{ backgroundColor: `${palette.style}` }}
|
||||||
|
onClick={() => setColor(palette)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{
|
||||||
|
!seeMore
|
||||||
|
? <div className="w-full" onClick={() => setSeeMore(true)}>더 보기</div>
|
||||||
|
: <>
|
||||||
|
{getSlicedList(extraPaletteList, 5).map((list) => (
|
||||||
|
<div className="flex flex-row gap-2.5">
|
||||||
|
{list.map((palette) => (
|
||||||
|
<div
|
||||||
|
className="rounded-full w-5 h-5 border border-gray-300"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${palette.style !== 'transparent' && palette.style}`,
|
||||||
|
background: `${palette.style === 'transparent' && 'linear-gradient(135deg, black 50%, white 50%)' }`
|
||||||
|
}}
|
||||||
|
onClick={() => setColor(palette)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
seeMore
|
||||||
|
? <div className="w-full" onClick={() => setSeeMore(false)}>접기</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</PopoverContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
src/ui/component/popover/SchedulePopover.tsx
Normal file
75
src/ui/component/popover/SchedulePopover.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Sheet, SheetContent, SheetHeader } from '@/components/ui/sheet';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { usePalette } from '@/hooks/use-palette';
|
||||||
|
import { type ColorPaletteType } from '@/const/ColorPalette';
|
||||||
|
import { ColorPickPopover } from './ColorPickPopover';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
interface ScheduleSheetProps {
|
||||||
|
date: Date | undefined;
|
||||||
|
popoverSide: 'left' | 'right';
|
||||||
|
popoverAlign: 'start' | 'end';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SchedulePopover = ({ date, popoverSide, popoverAlign }: ScheduleSheetProps) => {
|
||||||
|
const {
|
||||||
|
ColorPaletteType,
|
||||||
|
getPaletteNameList,
|
||||||
|
getMainPaletteList,
|
||||||
|
getAllPaletteList,
|
||||||
|
getCustomColor,
|
||||||
|
getPaletteByKey,
|
||||||
|
getStyle
|
||||||
|
} = usePalette();
|
||||||
|
const defaultColor = getPaletteByKey('Black');
|
||||||
|
const [scheduleColor, setScheduleColor] = useState(defaultColor);
|
||||||
|
const [colorPopoverOpen, setColorPopoverOpen] = useState(false);
|
||||||
|
const selectColor = (color: ColorPaletteType) => {
|
||||||
|
setScheduleColor(color);
|
||||||
|
setColorPopoverOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverContent
|
||||||
|
className="rounded-xl xl:w-[calc(100vw/4)] xl:max-w-[480px] min-w-[320px]"
|
||||||
|
align={popoverAlign} side={popoverSide}
|
||||||
|
>
|
||||||
|
<ScrollArea
|
||||||
|
className={
|
||||||
|
cn(
|
||||||
|
"[&>div>div:last-child]:hidden min-h-[125px] h-[calc(100vh/2)] p-2.5 w-full flex flex-col",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="w-full flex flex-row justify-center items-center gap-4">
|
||||||
|
<Popover open={colorPopoverOpen} onOpenChange={setColorPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-full w-5 h-5 border-2 border-gray-300',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${scheduleColor.style !== 'transparent' && scheduleColor.style}`,
|
||||||
|
background: `${scheduleColor.style === 'transparent' && 'linear-gradient(135deg, black 50%, white 50%)' }`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<ColorPickPopover
|
||||||
|
setColor={selectColor}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<Input
|
||||||
|
placeholder="제목"
|
||||||
|
className="font-bold border-t-0 border-r-0 border-l-0 p-0 border-b-2 rounded-none shadow-none border-indigo-300 focus-visible:ring-0 focus-visible:border-b-indigo-500"
|
||||||
|
style={{
|
||||||
|
fontSize: '20px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
205
src/ui/page/account/login/LoginPage.tsx
Normal file
205
src/ui/page/account/login/LoginPage.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardFooter } from '@/components/ui/card';
|
||||||
|
import { LoginSchema } from '@/data/form';
|
||||||
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PageRouting } from '@/const/PageRouting';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Validator } from '@/util/Validator';
|
||||||
|
import { LoginRequest } from '@/data/request/account/LoginRequest';
|
||||||
|
import { AccountNetwork } from '@/network/AccountNetwork';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [autoLogin, setAutoLogin] = useState<boolean>(localStorage.getItem('autoLogin') === 'true');
|
||||||
|
const { login } = useAuthStore();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const accountNetwork = new AccountNetwork();
|
||||||
|
const loginForm = useForm<z.infer<typeof LoginSchema>>({
|
||||||
|
resolver: zodResolver(LoginSchema),
|
||||||
|
defaultValues: {
|
||||||
|
id: "",
|
||||||
|
password: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { id, password } = { id: loginForm.watch('id'), password: loginForm.watch('password') };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('autoLogin', `${autoLogin}`);
|
||||||
|
}, [autoLogin]);
|
||||||
|
|
||||||
|
const moveToSignUpPage = useCallback(() => {
|
||||||
|
navigate(PageRouting["SIGN_UP"].path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const moveToResetPasswordPage = useCallback(() => {
|
||||||
|
navigate(PageRouting["RESET_PASSWORD"].path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const moveToHomePage = useCallback(() => {
|
||||||
|
navigate(PageRouting["HOME"].path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// TODO 33 로그인 기능 구현
|
||||||
|
const reqLogin = async () => {
|
||||||
|
if (isLoading) return;
|
||||||
|
const type = Validator.isEmail(id) ? 'email' : 'accountId';
|
||||||
|
|
||||||
|
const data: LoginRequest = new LoginRequest(type, id, password);
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const loginPromise = accountNetwork.login(data);
|
||||||
|
|
||||||
|
toast.promise(
|
||||||
|
loginPromise,
|
||||||
|
{
|
||||||
|
loading: "로그인 중입니다.",
|
||||||
|
success: (res) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
if (res.data.success) {
|
||||||
|
const data = {
|
||||||
|
accessToken: res.data.accessToken!,
|
||||||
|
refreshToken: res.data.refreshToken!
|
||||||
|
};
|
||||||
|
login({...data});
|
||||||
|
if (autoLogin) {
|
||||||
|
localStorage.setItem('auth-storage', JSON.stringify({ state: data }));
|
||||||
|
}
|
||||||
|
moveToHomePage();
|
||||||
|
return "로그인 성공";
|
||||||
|
} else {
|
||||||
|
throw new Error(res.data.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err: Error) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
return err.message || "에러 발생"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextSeparator = ({ text }: { text: string }) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-row items-center justify-center">
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
<span className="text-gray-500 px-3 text-sm text-muted-foregroud">{text}</span>
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnterKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!(e.key === 'Enter')) return;
|
||||||
|
const result = await loginForm.trigger();
|
||||||
|
if (!result) return;
|
||||||
|
await reqLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col justify-center items-center">
|
||||||
|
<Card className={isMobile ? "w-full pl-2 pr-2" : "w-md pl-2 pr-2"}>
|
||||||
|
<CardHeader>
|
||||||
|
로그인
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form id="form-login" className="w-full flex flex-col gap-2.5">
|
||||||
|
<Controller
|
||||||
|
name="id"
|
||||||
|
control={loginForm.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="form-login-id">아이디 또는 이메일</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
id="form-login-id"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
tabIndex={1}
|
||||||
|
onKeyDown={handleEnterKeyDown}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</Controller>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={loginForm.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<div className="w-full flex flex-row justify-between items-end">
|
||||||
|
<FieldLabel className="w-fit" htmlFor="form-login-password">비밀번호</FieldLabel>
|
||||||
|
<Button
|
||||||
|
className="p-0 bg-transparent hover:bg-transparent h-fit w-fit text-xs text-gray-400 hover:text-gray-500 cursor-pointer"
|
||||||
|
onClick={moveToResetPasswordPage}
|
||||||
|
type="button"
|
||||||
|
tabIndex={3}
|
||||||
|
>
|
||||||
|
비밀번호를 잊으셨습니까?
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
id="form-login-password"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
tabIndex={2}
|
||||||
|
onKeyDown={handleEnterKeyDown}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</Controller>
|
||||||
|
<div className="flex flex-row gap-2 mt-2">
|
||||||
|
<Checkbox
|
||||||
|
className={[
|
||||||
|
"data-[state=checked]:bg-indigo-500 data-[state=checked]:text-white"
|
||||||
|
, "data-[state=checked]:outline-none data-[state=checked]:border-0"
|
||||||
|
].join(' ')}
|
||||||
|
id="auto-login"
|
||||||
|
checked={autoLogin}
|
||||||
|
onCheckedChange={(value) => setAutoLogin(value === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="auto-login">자동 로그인</Label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter
|
||||||
|
className="w-full flex flex-col items-center gap-5"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="w-full bg-indigo-500 hover:bg-indigo-400"
|
||||||
|
type="button"
|
||||||
|
disabled={id.trim().length < 1 || password.trim().length < 1}
|
||||||
|
onClick={reqLogin}
|
||||||
|
>
|
||||||
|
로그인
|
||||||
|
</Button>
|
||||||
|
<TextSeparator text="또는" />
|
||||||
|
<Button
|
||||||
|
className="w-full text-violet-500 bg-white border border-violet-500 hover:bg-violet-500 hover:text-white"
|
||||||
|
type="button"
|
||||||
|
onClick={moveToSignUpPage}
|
||||||
|
>
|
||||||
|
회원가입
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
421
src/ui/page/account/resetPassword/ResetPasswordPage.tsx
Normal file
421
src/ui/page/account/resetPassword/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardFooter } from '@/components/ui/card';
|
||||||
|
import { ResetPasswordSchema } from '@/data/form';
|
||||||
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PageRouting } from '@/const/PageRouting';
|
||||||
|
import { Stepper, StepperContent, StepperIndicator, StepperItem, StepperNav, StepperPanel, StepperSeparator, StepperTrigger } from '@/components/ui/stepper';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||||
|
import { Validator } from '@/util/Validator';
|
||||||
|
import { Eye, EyeOff, LoaderCircleIcon, CircleCheckBigIcon } from 'lucide-react';
|
||||||
|
import { AccountNetwork } from '@/network/AccountNetwork';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
const steps = [1, 2, 3, 4];
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const accountNetwork = new AccountNetwork();
|
||||||
|
|
||||||
|
const resetPasswordForm = useForm<z.infer<typeof ResetPasswordSchema>>({
|
||||||
|
resolver: zodResolver(ResetPasswordSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
code: "",
|
||||||
|
password: "",
|
||||||
|
passwordConfirm: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { email, code, password, passwordConfirm } = resetPasswordForm.watch();
|
||||||
|
|
||||||
|
const moveToLoginPage = useCallback(() => {
|
||||||
|
navigate(PageRouting["LOGIN"].path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClickFirstStepButton = async () => {
|
||||||
|
if (isLoading) return;
|
||||||
|
if (!email || email.trim().length === 0) {
|
||||||
|
resetPasswordForm.setError('email', {
|
||||||
|
type: 'manual',
|
||||||
|
message: '이메일을 입력해주십시오'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isEmailValid = await resetPasswordForm.trigger('email');
|
||||||
|
if (!isEmailValid) {
|
||||||
|
resetPasswordForm.setError('email', {
|
||||||
|
type: 'validate',
|
||||||
|
message: '이메일 형식이 올바르지 않습니다'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const response = await accountNetwork.sendResetPasswordCode({ email: email });
|
||||||
|
const resData = response.data;
|
||||||
|
|
||||||
|
if (!resData.success) {
|
||||||
|
resetPasswordForm.setError('email', {
|
||||||
|
message: '서버 오류로 코드 발송에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStep(current => current + 1);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
resetPasswordForm.setError('email', {
|
||||||
|
message: '서버 오류로 코드 발송에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSecondStepOTPCompleted = async () => {
|
||||||
|
if (isLoading) return;
|
||||||
|
const codeValid = await resetPasswordForm.trigger('code');
|
||||||
|
if (!codeValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
email: email,
|
||||||
|
code: code
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await accountNetwork.verifyResetPasswordCode(data);
|
||||||
|
const resData = response.data;
|
||||||
|
console.log(resData);
|
||||||
|
if (!resData.success || !resData.verified) {
|
||||||
|
resetPasswordForm.setError('code', {
|
||||||
|
type: 'value',
|
||||||
|
message: resData.error
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStep(current => current + 1);
|
||||||
|
} catch (err) {
|
||||||
|
resetPasswordForm.setError('code', {
|
||||||
|
type: 'value',
|
||||||
|
message: '서버 오류로 코드 인증에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClickThirdStepButton = async () => {
|
||||||
|
if (isLoading) return;
|
||||||
|
const passwordValid = await resetPasswordForm.trigger('password');
|
||||||
|
if (!passwordValid) return;
|
||||||
|
|
||||||
|
const passwordConfirmValid = await resetPasswordForm.trigger('passwordConfirm');
|
||||||
|
if (!passwordConfirmValid) return;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
email: email,
|
||||||
|
password: password
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await accountNetwork.resetPassword(data);
|
||||||
|
const resData = response.data;
|
||||||
|
|
||||||
|
if (!resData.success) {
|
||||||
|
resetPasswordForm.setError('password', {
|
||||||
|
message: '서버 오류로 비밀번호 변경에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStep(current => current + 1);
|
||||||
|
} catch (err) {
|
||||||
|
resetPasswordForm.setError('password', {
|
||||||
|
message: '서버 오류로 비밀번호 변경에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnterKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentStep === 1) {
|
||||||
|
await handleClickFirstStepButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentStep === 3) {
|
||||||
|
await handleClickThirdStepButton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stepper
|
||||||
|
value={currentStep}
|
||||||
|
onValueChange={setCurrentStep}
|
||||||
|
className="w-full h-full flex flex-col justify-center items-center"
|
||||||
|
indicators={{
|
||||||
|
loading: <LoaderCircleIcon className="size-3.5 animate-spin" />
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card className="w-md pl-2 pr-2">
|
||||||
|
<CardHeader>
|
||||||
|
비밀번호 초기화
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<StepperNav className="select-none">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<StepperItem key={step} step={step} loading={isLoading}>
|
||||||
|
<StepperTrigger asChild>
|
||||||
|
<StepperIndicator
|
||||||
|
className={[
|
||||||
|
`transition-all duration-300`,
|
||||||
|
`bg-accent text-accent-foreground rounded-full text-xs data-[state=completed]:bg-indigo-500 data-[state=completed]:text-primary-foreground data-[state=active]:bg-indigo-300 data-[state=active]:text-primary-foreground`,
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{step}
|
||||||
|
</StepperIndicator>
|
||||||
|
</StepperTrigger>
|
||||||
|
{
|
||||||
|
steps.length > step
|
||||||
|
&& <StepperSeparator
|
||||||
|
className="transition-all duration-300 group-data-[state=completed]/step:bg-indigo-500"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</StepperItem>
|
||||||
|
))}
|
||||||
|
</StepperNav>
|
||||||
|
</CardContent>
|
||||||
|
<CardContent>
|
||||||
|
<StepperPanel>
|
||||||
|
<StepperContent value={1} key={1}>
|
||||||
|
<Field data-invalid={resetPasswordForm.formState.errors.email?.message ? true : false}>
|
||||||
|
<FieldLabel htmlFor="reseet-password-email">이메일</FieldLabel>
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={resetPasswordForm.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="email"
|
||||||
|
id="reset-password-email"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
onKeyDown={handleEnterKeyDown}
|
||||||
|
/>
|
||||||
|
<FieldError className="font-[12px]" errors={[fieldState.error]} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={2} key={2}>
|
||||||
|
<Controller
|
||||||
|
name="code"
|
||||||
|
control={resetPasswordForm.control}
|
||||||
|
render={
|
||||||
|
({ field, fieldState }) => (
|
||||||
|
<div className="w-full flex flex-col justify-center gap-5">
|
||||||
|
<FieldLabel htmlFor="reset-password-code">코드 입력</FieldLabel>
|
||||||
|
<div className="flex flex-row justify-center items-center">
|
||||||
|
<InputOTP
|
||||||
|
maxLength={8}
|
||||||
|
inputMode="text"
|
||||||
|
id="reset-password-code"
|
||||||
|
onComplete={handleSecondStepOTPCompleted}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<InputOTPGroup
|
||||||
|
className="gap-2.5 *:data-[slot=input-otp-slot]:rounded-md *:data-[slot=input-otp-slot]:border"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
[0, 1, 2, 3, 4, 5, 6, 7].map((idx) => (
|
||||||
|
<InputOTPSlot index={idx} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={3} key={3}>
|
||||||
|
<div className="w-full flex flex-col gap-5 items-start">
|
||||||
|
<FieldLabel htmlFor="reset-password-password">새 비밀번호</FieldLabel>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={resetPasswordForm.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type={ showPassword ? "text" : "password" }
|
||||||
|
id="reset-password-password"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
onKeyDown={handleEnterKeyDown}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute top-1/2 right-2 -translate-y-1/2"
|
||||||
|
onClick={() => setShowPassword(prev => !prev)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
showPassword
|
||||||
|
? <EyeOff size={14} />
|
||||||
|
: <Eye size={14} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<FieldError className="text-[12px]" errors={[fieldState.error]} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldLabel htmlFor="reset-password-password-confirm">새 비밀번호 확인</FieldLabel>
|
||||||
|
<Controller
|
||||||
|
name="passwordConfirm"
|
||||||
|
control={resetPasswordForm.control}
|
||||||
|
render={({ field, fieldState}) => (
|
||||||
|
<>
|
||||||
|
<div className="w-full relative">
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type={ showPasswordConfirm ? "text" : "password" }
|
||||||
|
id="reset-password-password-confirm"
|
||||||
|
className="pr-10"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
onKeyDown={handleEnterKeyDown}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute top-1/2 right-2 -translate-y-1/2"
|
||||||
|
onClick={() => setShowPasswordConfirm(prev => !prev)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
showPasswordConfirm
|
||||||
|
? <EyeOff size={14} />
|
||||||
|
: <Eye size={14} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<FieldError className="text-[12px]" errors={[fieldState.error]} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={4} key={4}>
|
||||||
|
<div className="w-full flex flex-col justify-center items-center gap-5">
|
||||||
|
<CircleCheckBigIcon size={50} className="text-indigo-500" />
|
||||||
|
<Label className="font-extrabold text-xl">비밀번호 변경 완료</Label>
|
||||||
|
</div>
|
||||||
|
</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<StepperPanel className="w-full">
|
||||||
|
<StepperContent value={1}>
|
||||||
|
<div className="w-full flex flex-row items-center gap-5">
|
||||||
|
<Button
|
||||||
|
className="flex-8 bg-indigo-500 hover:bg-indigo-400"
|
||||||
|
type="button"
|
||||||
|
form="form-reset-password"
|
||||||
|
disabled={
|
||||||
|
(email.trim().length < 1)
|
||||||
|
}
|
||||||
|
onClick={handleClickFirstStepButton}
|
||||||
|
>
|
||||||
|
인증 번호 발송
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 bg-stone-300 text-white hover:bg-stone-400"
|
||||||
|
onClick={moveToLoginPage}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={2} key={2}>
|
||||||
|
<div className="w-full flex flex-row justify-end items-center gap-5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="bg-stone-300 text-white hover:bg-stone-400"
|
||||||
|
onClick={moveToLoginPage}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={3} key={3}>
|
||||||
|
<div className="w-full flex flex-row align-center gap-5">
|
||||||
|
<Button
|
||||||
|
className="flex-8 bg-indigo-500 hover:bg-indigo-400"
|
||||||
|
type="button"
|
||||||
|
form="form-reset-password"
|
||||||
|
disabled={
|
||||||
|
(password.trim().length < 1)
|
||||||
|
&& (passwordConfirm.trim().length < 1)
|
||||||
|
}
|
||||||
|
onClick={handleClickThirdStepButton}
|
||||||
|
>
|
||||||
|
비밀번호 변경
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 bg-stone-300 text-white hover:bg-stone-400"
|
||||||
|
onClick={moveToLoginPage}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={4} key={4}>
|
||||||
|
<div className="w-full flex flex-row justify-end items-center gap-5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="bg-stone-500 text-white hover:bg-stone-400"
|
||||||
|
onClick={moveToLoginPage}
|
||||||
|
>
|
||||||
|
로그인 화면으로 이동
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</Stepper>
|
||||||
|
)
|
||||||
|
}
|
||||||
285
src/ui/page/account/signup/SignUpPage.tsx
Normal file
285
src/ui/page/account/signup/SignUpPage.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||||
|
import { SignUpSchema } from '@/data/form';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import EmailVerificationModal from '@/ui/component/modal/EmailVerificationModal';
|
||||||
|
import { CheckDuplicationRequest, SignupRequest } from '@/data/request';
|
||||||
|
import { AccountNetwork } from '@/network/AccountNetwork';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PageRouting } from '@/const/PageRouting';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
|
export default function SignUpPage() {
|
||||||
|
const [emailVerificationModalOpen, setEmailVerificationModalOpen] = useState<boolean>(false);
|
||||||
|
const [isCheckedEmailDuplication, setIsCheckedEmailDuplication] = useState<boolean>(false);
|
||||||
|
const [isCheckedAccountIdDuplication, setIsCheckedAccountIdDuplication] = useState<boolean>(false);
|
||||||
|
const [duplicationCheckedEmail, setDuplicationCheckedEmail] = useState<string>("");
|
||||||
|
const [duplicationCheckedAccountId, setDuplicationCheckedAccountId] = useState<string>("");
|
||||||
|
|
||||||
|
const accountNetwork = new AccountNetwork();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const signUpForm = useForm<z.infer<typeof SignUpSchema>>({
|
||||||
|
resolver: zodResolver(SignUpSchema),
|
||||||
|
defaultValues: {
|
||||||
|
accountId: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
passwordConfirm: "",
|
||||||
|
name: "",
|
||||||
|
nickname: "",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const goToLogin = useCallback(() => {
|
||||||
|
navigate(PageRouting["LOGIN"].path);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const checkDuplication = async (type: 'email' | 'accountId', value: string) => {
|
||||||
|
const data: CheckDuplicationRequest = new CheckDuplicationRequest(type, value);
|
||||||
|
return await accountNetwork.checkDuplication(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signup = async () => {
|
||||||
|
const { email, accountId, name, nickname, password } = signUpForm.getValues();
|
||||||
|
const data: SignupRequest = new SignupRequest(accountId, email, name, nickname, password);
|
||||||
|
|
||||||
|
const signupPromise = accountNetwork.signup(data);
|
||||||
|
|
||||||
|
toast.promise(
|
||||||
|
signupPromise,
|
||||||
|
{
|
||||||
|
loading: "회원가입 진행 중입니다.",
|
||||||
|
success: (res) => {
|
||||||
|
if (!res.data.success) return "회원가입에 실패하였습니다.\n잠시 후 다시 시도해주십시오.";
|
||||||
|
|
||||||
|
return <SuccessToast onClose={goToLogin} />
|
||||||
|
},
|
||||||
|
error: "회원가입에 실패하였습니다.\n잠시 후 다시 시도해주십시오.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnChangeAccountId = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setIsCheckedAccountIdDuplication(
|
||||||
|
e.currentTarget.value === duplicationCheckedAccountId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setIsCheckedEmailDuplication(
|
||||||
|
e.currentTarget.value === duplicationCheckedEmail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDuplicationCheckButtonClick = async (type: 'email' | 'accountId') => {
|
||||||
|
const value = signUpForm.getValues(type);
|
||||||
|
const duplicatedMessage = type === 'email' ? '사용할 수 없는 이메일입니다.' : '사용할 수 없는 아이디입니다.';
|
||||||
|
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
const isDuplicated = (await checkDuplication(type, value)).data.isDuplicated;
|
||||||
|
|
||||||
|
if (isDuplicated) {
|
||||||
|
signUpForm.setError(type, { message: duplicatedMessage });
|
||||||
|
} else {
|
||||||
|
signUpForm.clearErrors(type);
|
||||||
|
if (type === 'email') {
|
||||||
|
setIsCheckedEmailDuplication(true);
|
||||||
|
setDuplicationCheckedEmail(value);
|
||||||
|
} else {
|
||||||
|
setIsCheckedAccountIdDuplication(true);
|
||||||
|
setDuplicationCheckedAccountId(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnSignUpButtonClick = () => {
|
||||||
|
if (!isCheckedAccountIdDuplication) {
|
||||||
|
signUpForm.setError("accountId", { message: "아이디 중복 확인이 필요합니다."});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCheckedEmailDuplication) {
|
||||||
|
signUpForm.setError("email", { message: "이메일 중복 확인이 필요합니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmailVerificationModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"w-full h-full flex flex-col justify-center items-center"}>
|
||||||
|
|
||||||
|
<Card className={isMobile ? "w-full pl-2 pr-2" : "w-md pl-2 pr-2"}>
|
||||||
|
<CardHeader>회원가입</CardHeader>
|
||||||
|
<ScrollArea className="h-72 [&>div>div:last-child]:hidden">
|
||||||
|
<CardContent>
|
||||||
|
<form id="form-signup">
|
||||||
|
<FieldGroup>
|
||||||
|
<Controller
|
||||||
|
name="accountId"
|
||||||
|
control={signUpForm.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="form-signup-account-id">아이디</FieldLabel>
|
||||||
|
<div id="accountId-group" className="w-full flex flex-row justify-between gap-2.5">
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id="form-signup-account-id"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
onInput={handleOnChangeAccountId}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDuplicationCheckButtonClick('accountId')}
|
||||||
|
className="bg-indigo-500 hover:bg-indigo-400"
|
||||||
|
>
|
||||||
|
중복 확인
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{ isCheckedAccountIdDuplication && <p className="text-green-500 text-sm font-normal">사용할 수 있는 아이디입니다</p> }
|
||||||
|
<FieldError errors={[fieldState.error]}/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
control={signUpForm.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="form-signup-name">이름</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id="form-signup-name"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="nickname"
|
||||||
|
control={signUpForm.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="form-signup-nickname">닉네임</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id="form-signup-nickname"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={signUpForm.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="form-signup-email">이메일</FieldLabel>
|
||||||
|
<div id="email-group" className="w-full flex flex-row justify-between gap-2.5">
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id="form-signup-email"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
placeholder="example@domain.com"
|
||||||
|
type="email"
|
||||||
|
onInput={handleOnChangeEmail}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDuplicationCheckButtonClick('email')}
|
||||||
|
className="bg-indigo-500 hover:bg-indigo-400"
|
||||||
|
>
|
||||||
|
중복 확인
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{ isCheckedEmailDuplication && <p className="text-green-500 text-sm font-normal">사용할 수 있는 이메일입니다</p> }
|
||||||
|
<FieldError errors={[fieldState.error]}/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={signUpForm.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="form-signup-password">비밀번호</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id="form-signup-password"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="passwordConfirm"
|
||||||
|
control={signUpForm.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="form-signup-password-confirm">비밀번호 확인</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id="form-signup-password-confirm"
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</ScrollArea>
|
||||||
|
<CardFooter>
|
||||||
|
<EmailVerificationModal
|
||||||
|
trigger={
|
||||||
|
<Button type="button" onClick={handleOnSignUpButtonClick} className="0">
|
||||||
|
회원가입
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
email={duplicationCheckedEmail}
|
||||||
|
open={emailVerificationModalOpen} // ✅ 부모 상태 연결
|
||||||
|
setOpen={setEmailVerificationModalOpen} // ✅ 부모 상태 변경 함수 전달
|
||||||
|
onVerifySuccess={signup} // ✅ 인증 성공 시 signup 호출
|
||||||
|
/>
|
||||||
|
</CardFooter>
|
||||||
|
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuccessToast({ onClose }: { onClose: () => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => onClose(), 3000); // 3초 후 이동
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
회원가입 성공!
|
||||||
|
<button onClick={onClose}>로그인 페이지로 이동</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/ui/page/home/HomePage.tsx
Normal file
7
src/ui/page/home/HomePage.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const HomePage = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-column">
|
||||||
|
HomePage
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
11
src/ui/page/schedule/ScheduleMainPage.tsx
Normal file
11
src/ui/page/schedule/ScheduleMainPage.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { CustomCalendar } from "@/ui/component/calendar/CustomCalendar";
|
||||||
|
|
||||||
|
export function ScheduleMainPage() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full h-full p-2"
|
||||||
|
>
|
||||||
|
<CustomCalendar />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel, FieldLegend } from '@/components/ui/field';
|
|
||||||
import { SignUpSchema } from '@/data/form';
|
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
|
||||||
import * as z from 'zod';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import EmailVerificationModal from '@/ui/component/modal/EmailVerificationModal';
|
|
||||||
|
|
||||||
export default function SignUpPage() {
|
|
||||||
const [isCheckedEmailDuplication, setIsCheckdEmailDupliation] = useState<boolean>(false);
|
|
||||||
const [isEmailVerificated, setIsEmailVerificated] = useState<boolean>(false);
|
|
||||||
const [duplicationCheckedEmail, setDuplicationCheckedEmail] = useState<string>("");
|
|
||||||
|
|
||||||
const signUpForm = useForm<z.infer<typeof SignUpSchema>>({
|
|
||||||
resolver: zodResolver(SignUpSchema),
|
|
||||||
defaultValues: {
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
passwordConfirm: "",
|
|
||||||
name: "",
|
|
||||||
nickname: "",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOnChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setIsCheckdEmailDupliation(
|
|
||||||
e.currentTarget.value === duplicationCheckedEmail
|
|
||||||
);
|
|
||||||
// setIsEmailVerificated(
|
|
||||||
// e.currentTarget.value === duplicationCheckedEmail
|
|
||||||
// );
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEmailDuplicationCheckButtonClick = () => {
|
|
||||||
console.log(signUpForm.getValues("email"));
|
|
||||||
setDuplicationCheckedEmail(signUpForm.getValues("email"));
|
|
||||||
setIsCheckdEmailDupliation(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOnSubmitSignUpForm = () => {
|
|
||||||
if (!isCheckedEmailDuplication) {
|
|
||||||
signUpForm.setError("email", { message: "이메일 중복 확인이 필요합니다." });
|
|
||||||
}
|
|
||||||
// if (!isEmailVerificated) {
|
|
||||||
// signUpForm.setError("email", { message: "이메일 인증이 완료되지 않았습니다." });
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex flex-col justify-center items-center">
|
|
||||||
<Card className="w-md pl-2 pr-2">
|
|
||||||
<CardHeader>회원가입</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form id="form-signup" onSubmit={signUpForm.handleSubmit(handleOnSubmitSignUpForm)}>
|
|
||||||
<FieldGroup>
|
|
||||||
<Controller
|
|
||||||
name="name"
|
|
||||||
control={signUpForm.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field data-invalid={fieldState.invalid}>
|
|
||||||
<FieldLabel htmlFor="form-signup-name">이름</FieldLabel>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
id="form-signup-name"
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
name="nickname"
|
|
||||||
control={signUpForm.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field data-invalid={fieldState.invalid}>
|
|
||||||
<FieldLabel htmlFor="form-signup-nickname">닉네임</FieldLabel>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
id="form-signup-nickname"
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
name="email"
|
|
||||||
control={signUpForm.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field data-invalid={fieldState.invalid}>
|
|
||||||
<FieldLabel htmlFor="form-signup-email">이메일</FieldLabel>
|
|
||||||
<div id="email-group" className="w-full flex flex-row justify-between gap-2.5">
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
id="form-signup-email"
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
placeholder="example@domain.com"
|
|
||||||
type="email"
|
|
||||||
onInput={handleOnChangeEmail}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleEmailDuplicationCheckButtonClick}
|
|
||||||
>
|
|
||||||
중복 확인
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<FieldError errors={[fieldState.error]}/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
name="password"
|
|
||||||
control={signUpForm.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field data-invalid={fieldState.invalid}>
|
|
||||||
<FieldLabel htmlFor="form-signup-password">비밀번호</FieldLabel>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
id="form-signup-password"
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
name="passwordConfirm"
|
|
||||||
control={signUpForm.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field data-invalid={fieldState.invalid}>
|
|
||||||
<FieldLabel htmlFor="form-signup-password-confirm">비밀번호 확인</FieldLabel>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
id="form-signup-password-confirm"
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<EmailVerificationModal
|
|
||||||
trigger={
|
|
||||||
<Button type="submit" form="form-signup">
|
|
||||||
회원가입
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
email={duplicationCheckedEmail}
|
|
||||||
handler={() => {}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
23
src/util/Validator.ts
Normal file
23
src/util/Validator.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export class Validator {
|
||||||
|
static isEmail = (value: any) => {
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
const email = value.trim();
|
||||||
|
return /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
static validatePasswordFormat = (password: string): boolean => {
|
||||||
|
if (password.length < 8) return false;
|
||||||
|
if (password.includes(' ')) return false;
|
||||||
|
|
||||||
|
const alphabets = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
const numbers = '0123456789';
|
||||||
|
const specials = '!@#$%^';
|
||||||
|
|
||||||
|
if (!alphabets.includes(password[0])) return false;
|
||||||
|
|
||||||
|
const hasNumber = [...numbers].some((char) => password.includes(char));
|
||||||
|
const hasSpecial = [...specials].some((char) => password.includes(char));
|
||||||
|
|
||||||
|
return hasNumber && hasSpecial;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5185
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss()
|
tailwindcss()
|
||||||
|
|||||||
Reference in New Issue
Block a user