diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..62a81b5e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,34 @@ +name: Deploy + +on: + push: + branches: ['develop'] + +jobs: + build: + runs-on: ubuntu-latest + container: pandoc/latex + steps: + - uses: actions/checkout@v2 + + - name: Install mustache (to update the date) + run: apk add ruby && gem install mustache + + - name: creates output + run: sh ./build.sh + + - name: Pushes to another repository + id: push_directory + uses: cpina/github-action-push-to-another-repository@main + env: + API_TOKEN_GITHUB: ${{ secrets.AUTO_ACTIONS }} + with: + source-directory: 'output' + destination-github-username: waldls + destination-repository-name: DeviceLife_Frontend + user-email: ${{ secrets.EMAIL }} + commit-message: ${{ github.event.commits[0].message }} + target-branch: develop + + - name: Test get variable exported by push-to-another-repository + run: echo $DESTINATION_CLONED_DIRECTORY \ No newline at end of file diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..42f2cea6 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,43 @@ +name: Preview + +on: + pull_request: + branches: ['develop'] + +jobs: + vercel-preview: + runs-on: ubuntu-latest + + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + steps: + - uses: actions/checkout@v4 + - name: Install Vercel CLI + run: npm install --global vercel@latest && npm install --global pnpm + - name: Get Vercel Environment Variables + run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} + - name: Build Project Artifacts + run: vercel build --token=${{ secrets.VERCEL_TOKEN }} + - name: Deploy Project Artifacts to Vercel + id: deploy + + run: | + + vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} > vercel-output.txt + echo "preview_url=$(cat vercel-output.txt)" >> $GITHUB_OUTPUT + + - name: Comment PR with Preview URL + uses: thollander/actions-comment-pull-request@v2 + with: + message: | + โœ… PREVIEW ${{ steps.deploy.outputs.preview_url }} + +permissions: + contents: read + pages: write + deployments: write + id-token: write + issues: write + pull-requests: write \ No newline at end of file diff --git a/.gitignore b/.gitignore index bb92d236..56f49d75 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ dist-ssr *.sln *.sw? .env +.vercel +.env*.local diff --git a/.vscode/settings.json b/.vscode/settings.json index 4b519d63..4189f115 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,10 @@ { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", - "eslint.validate": ["typescript", "typescriptreact"] + "eslint.validate": ["typescript", "typescriptreact"], + + "[typescript]": { "editor.formatOnSave": false }, + "[typescriptreact]": { "editor.formatOnSave": false }, + + "prettier.prettierPath": "./node_modules/prettier" } diff --git a/README.md b/README.md index 28c1fdc3..9bd9036a 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,82 @@ # ๐Ÿ“ฑDevice Life - Frontend -> ์ˆ˜๋งŽ์€ ๋””๋ฐ”์ด์Šค ์ค‘ ์œ ์ €์—๊ฒŒ ์ตœ์ ์˜ ์กฐํ•ฉ์„ ์ œ๊ณตํ•˜๋‹ค +> ์ตœ์ ์˜ ๊ธฐ๊ธฐ์กฐํ•ฉ ์›น ์„œ๋น„์Šค > UMC 9th Project - Client Repository +![KakaoTalk_20260212_224313434](https://github.com/user-attachments/assets/2deaa37b-9ce5-4c96-9201-beccaff84793) + ## ๐Ÿ“– ํ”„๋กœ์ ํŠธ ์†Œ๊ฐœ (Project Overview) -์ด ์ €์žฅ์†Œ๋Š” Device Life์˜ ํด๋ผ์ด์–ธํŠธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ฝ”๋“œ๋ฅผ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ์ง๊ด€์ ์œผ๋กœ ๊ธฐ๊ธฐ๋ฅผ ํƒ์ƒ‰ํ•˜๊ณ , ์กฐํ•ฉ์„ ์ƒ์„ฑํ•˜๋ฉฐ, ์‹ค์‹œ๊ฐ„์œผ๋กœ ์‹œ๊ฐ์ ์ธ ํ‰๊ฐ€ ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” UI/UX ๊ตฌํ˜„์— ์ง‘์ค‘ํ•ฉ๋‹ˆ๋‹ค. +์ด ์ €์žฅ์†Œ๋Š” ์ตœ์ ์˜ ๊ธฐ๊ธฐ ์กฐํ•ฉ ์ถ”์ฒœ ์„œ๋น„์Šค 'Device Life'์˜ ์›น ํด๋ผ์ด์–ธํŠธ ์ฝ”๋“œ๋ฅผ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ์ˆ˜๋งŽ์€ ์Šค๋งˆํŠธ ๊ธฐ๊ธฐ ์‚ฌ์ด์—์„œ ์ž์‹ ์—๊ฒŒ ๋งž๋Š” ์ตœ์ ์˜ ์กฐํ•ฉ์„ ์ฐพ์„ ์ˆ˜ ์žˆ๋„๋ก, ์ง๊ด€์ ์ธ ํƒ์ƒ‰ ๊ฒฝํ—˜๊ณผ ๋กœ์ง ๊ธฐ๋ฐ˜์˜ ์‹ค์‹œ๊ฐ„ ํ‰๊ฐ€ ํ”ผ๋“œ๋ฐฑ์„ ์ œ๊ณตํ•˜๋Š” ์™„์„ฑ๋„ ๋†’์€ UI/UX ๊ตฌํ˜„์— ์ง‘์ค‘ํ•ฉ๋‹ˆ๋‹ค. ### โœจ ํ”„๋ก ํŠธ์—”๋“œ ํ•ต์‹ฌ ๊ธฐ๋Šฅ (Key Features) -* ์กฐํ•ฉ ์›Œํฌ์ŠคํŽ˜์ด์Šค UI: ๊ธฐ๊ธฐ ์ถ”๊ฐ€/์‚ญ์ œ ์‹œ ๋ถ€๋“œ๋Ÿฌ์šด ์ธํ„ฐ๋ž™์…˜ ๋ฐ ์ƒํƒœ ๊ด€๋ฆฌ ๊ตฌํ˜„ -* ์‹ค์‹œ๊ฐ„ ํ‰๊ฐ€ ํ”ผ๋“œ๋ฐฑ: ์ƒํƒœ๊ณ„, ์ถฉ์ „, ์ปฌ๋Ÿฌ ์ ์ˆ˜ ๋ฐ์ดํ„ฐ๋ฅผ ์‹œ๊ฐ์  ๊ทธ๋ž˜ํ”„๋‚˜ ์ˆ˜์น˜๋กœ ์ฆ‰์‹œ ๋ Œ๋”๋ง -* ๊ธฐ๊ธฐ ํƒ์ƒ‰ ๋ฐ ํ•„ํ„ฐ UI: ๊ฐ€๊ฒฉ๋Œ€ ์กฐ์ ˆ์„ ์œ„ํ•œ Range Slider ์ปดํฌ๋„ŒํŠธ ๋ฐ ์นดํ…Œ๊ณ ๋ฆฌ ์นฉ ๊ตฌํ˜„ -* ๋ผ์ดํ”„์Šคํƒ€์ผ ์„ ํƒ: ์ด๋ฏธ์ง€ ์นด๋“œ ๋ฐ ๋ธŒ๋žœ๋“œ ๋กœ๊ณ ๋ฅผ ํ™œ์šฉํ•œ ์ง๊ด€์ ์ธ ์‚ฌ์šฉ์ž ์ž…๋ ฅ ์ธํ„ฐํŽ˜์ด์Šค -* ์ธํ„ฐ๋ž™์…˜: ๋‚ด ์กฐํ•ฉ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์˜ ์Šค์™€์ดํ”„ ์‚ญ์ œ ๋ฐ ๋ชจ๋‹ฌ ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ฒ˜๋ฆฌ +- ๋™์  ์œ ์ € ๊ถŒํ•œ ๋ฐ GNB ๋ Œ๋”๋ง: ๋กœ๊ทธ์ธ ์œ ๋ฌด ๋ฐ ์˜จ๋ณด๋”ฉ ์ƒํƒœ์— ๋”ฐ๋ผ ๋ฉ”๋‰ด ๊ตฌ์„ฑ๊ณผ ์ ‘๊ทผ ๊ถŒํ•œ ์ œ์–ด +- ์˜จ๋ณด๋”ฉ ํ”Œ๋กœ์šฐ: ์‹ ๊ทœ ์œ ์ €๋ฅผ ์œ„ํ•œ ์Šคํ”Œ๋ž˜์‹œ ํ™”๋ฉด, ๋ผ์ดํ”„์Šคํƒ€์ผ ํƒœ๊ทธ ์„ ํƒ, ์ฒซ ์กฐํ•ฉ ์ƒ์„ฑ์œผ๋กœ ์ด์–ด์ง€๋Š” ๋‹จ๊ณ„๋ณ„ ํผ ์œ„์ €๋“œ ๊ตฌํ˜„ +- ๋‹ค์ค‘ ํ•„ํ„ฐ ๊ธฐ๋ฐ˜ ๊ธฐ๊ธฐ ํƒ์ƒ‰: ์นดํ…Œ๊ณ ๋ฆฌ ์นฉ, ๋‹จ์ผ/๋‹ค์ค‘ ์ฒดํฌ๋ฐ•์Šค ํ•„ํ„ฐ, 4๊ฐ€์ง€ ์ •๋ ฌ ์˜ต์…˜์„ ์กฐํ•ฉํ•œ ๊ฒ€์ƒ‰ ์ธํ„ฐํŽ˜์ด์Šค ์ œ๊ณต +- ์กฐํ•ฉ ๋‹ด๊ธฐ ์‹œ์Šคํ…œ: ํ˜„์žฌ ์„ ํƒ๋œ ์กฐํ•ฉ ์ƒํƒœ๋ฅผ ์œ ์ง€ํ•˜๋ฉฐ ์ค‘๋ณต ์ €์žฅ +- ๋ผ์ดํ”„์Šคํƒ€์ผ ์ž๋™ ๋กœํ…Œ์ด์…˜: ์œ ์ € ์•ก์…˜์— ๋ฐ˜์‘ํ•˜์—ฌ ์ •์ง€/์žฌ์ƒ๋˜๋Š” ์‹œ๊ฐ์  ํƒœ๊ทธ ๋กœํ…Œ์ด์…˜ ๋ฐ ๊ธฐ๊ธฐ ํ๋ ˆ์ด์…˜ ์นด๋“œ ๊ตฌํ˜„ +- ๋กœ์ง ๊ธฐ๋ฐ˜ ํ‰๊ฐ€ ๋ฆฌํฌํŠธ: ๊ธฐ๊ธฐ ๊ฐ„ ์—ฐ๋™์„ฑ, ํŽธ์˜์„ฑ, ๋ผ์ดํ”„์Šคํƒ€์ผ ์ ํ•ฉ๋„ ๋ถ„์„ ๋‚ด์šฉ์„ ๋งˆ์ดํŽ˜์ด์ง€ ๋‚ด ํ•œ ์ค„ ํ‰๊ฐ€ ๋ฆฌํฌํŠธ๋กœ ๋ Œ๋”๋ง --- +## ๐Ÿง‘๐Ÿปโ€๐Ÿ’ป ํŒ€์› (Contributors) + +| | | | +| :---------------------------------------------------------------: | :-----------------------------------------------------------------: | :---------------------------------------------------------------: | +| ๋ฐ•์œ ๋ฏผ
[@waldls](https://github.com/waldls) | ์ด์„ ์šฐ
[@Seony777](https://github.com/Seony777) | ์ž„๋ณ‘ํ›ˆ
[@H-un1](https://github.com/H-un1) | +| **Frontend Lead** | **Frontend** | **Frontend** | -## ๐Ÿ› ๏ธ ๊ธฐ์ˆ  ์Šคํƒ (Tech Stack) -| Category | Technology | -| :--- | :--- | -| **Language & Framework** | ![TypeScript](https://img.shields.io/badge/-TypeScript-3178C6?style=flat&logo=typescript&logoColor=white) ![React](https://img.shields.io/badge/-React-61DAFB?style=flat&logo=react&logoColor=black) ![Vite](https://img.shields.io/badge/-Vite-646CFF?style=flat&logo=vite&logoColor=white) | -| **Styling** | ![Tailwind CSS](https://img.shields.io/badge/-Tailwind%20CSS-38B2AC?style=flat&logo=tailwind-css&logoColor=white) | -| **State Management** | **Zustand** (Client), **TanStack Query** (Server) | -| **Data Fetching** | Axios, TanStack Query | -| **Tools** | ESLint, Prettier, npm | -| **DevOps** | Git, GitHub, Vercel | --- +## ๐Ÿ› ๏ธ ๊ธฐ์ˆ  ์Šคํƒ (Tech Stack) -## ๐ŸŒ Git-flow ์ „๋žต (Git-flow Strategy) +| ์—ญํ•  | ์ข…๋ฅ˜ | ์„ ์ • ๊ทผ๊ฑฐ | +| :-- | :-- | :-- | +| **Language & Framework** | ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white) ![React](https://img.shields.io/badge/React-61DAFB?style=for-the-badge&logo=react&logoColor=black) ![Vite](https://img.shields.io/badge/Vite-646CFF?style=for-the-badge&logo=vite&logoColor=white) | TypeScript๋กœ ํƒ€์ž… ์•ˆ์ •์„ฑ์„ ํ™•๋ณดํ•˜๊ณ , React ๊ธฐ๋ฐ˜ ์ปดํฌ๋„ŒํŠธ UI๋ฅผ ๊ตฌ์„ฑํ–ˆ์œผ๋ฉฐ, Vite์˜ ๋น ๋ฅธ ๋ฒˆ๋“ค๋ง๊ณผ HMR๋กœ ๊ฐœ๋ฐœ ์ƒ์‚ฐ์„ฑ์„ ๋†’์˜€์Šต๋‹ˆ๋‹ค. | +| **Styling** | ![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-06B6D4?style=for-the-badge&logo=tailwindcss&logoColor=white) | ์œ ํ‹ธ๋ฆฌํ‹ฐ ๊ธฐ๋ฐ˜ ์Šคํƒ€์ผ๋ง์œผ๋กœ ๋น ๋ฅด๊ณ  ์ผ๊ด€๋œ UI๋ฅผ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค. | +| **UI Utilities** | ![SVGR](https://img.shields.io/badge/SVGR-FFB13B?style=for-the-badge&logo=svg&logoColor=black) ![clsx](https://img.shields.io/badge/clsx-000000?style=for-the-badge) | SVGR๋กœ SVG๋ฅผ React ์ปดํฌ๋„ŒํŠธ๋กœ ๊ด€๋ฆฌํ•ด ์žฌ์‚ฌ์šฉ์„ฑ์„ ๋†’์˜€๊ณ , clsx๋กœ ์กฐ๊ฑด๋ถ€ className ๋กœ์ง์„ ๊ฐ„๊ฒฐํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. | +| **State Management** | ![Zustand](https://img.shields.io/badge/Zustand-000000?style=for-the-badge&logoColor=white) ![TanStack Query](https://img.shields.io/badge/TanStack_Query-FF4154?style=for-the-badge&logo=reactquery&logoColor=white) | Zustand๋กœ ํด๋ผ์ด์–ธํŠธ ์ƒํƒœ๋ฅผ ๊ฐ„๋‹จํžˆ ๊ด€๋ฆฌํ•˜๊ณ , TanStack Query๋กœ ์„œ๋ฒ„ ์ƒํƒœ/์บ์‹ฑ์„ ์„ ์–ธ์ ์œผ๋กœ ๊ด€๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. | +| **Data Fetching** | ![Axios](https://img.shields.io/badge/Axios-5A29E4?style=for-the-badge&logo=axios&logoColor=white) ![TanStack Query](https://img.shields.io/badge/TanStack_Query-FF4154?style=for-the-badge&logo=reactquery&logoColor=white) | Axios๋กœ HTTP ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•˜๊ณ , TanStack Query๋กœ ์š”์ฒญ ์ƒํƒœ/์บ์‹ฑ/๋™๊ธฐํ™” ๊ด€๋ฆฌ๋ฅผ ์ˆ˜ํ–‰ํ–ˆ์Šต๋‹ˆ๋‹ค. | +| **Tools** | ![ESLint](https://img.shields.io/badge/ESLint-4B32C3?style=for-the-badge&logo=eslint&logoColor=white) ![Prettier](https://img.shields.io/badge/Prettier-F7B93E?style=for-the-badge&logo=prettier&logoColor=black) | ESLint๋กœ ์ฝ”๋“œ ๊ทœ์น™์„ ํ†ต์ผํ•˜๊ณ , Prettier๋กœ ํฌ๋งท์„ ์ž๋™ํ™”ํ•ด ์ผ๊ด€๋œ ์ฝ”๋“œ ์Šคํƒ€์ผ์„ ์œ ์ง€ํ–ˆ์Šต๋‹ˆ๋‹ค. | +| **DevOps** | ![Git](https://img.shields.io/badge/Git-F05032?style=for-the-badge&logo=git&logoColor=white) ![GitHub](https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white) ![Vercel](https://img.shields.io/badge/Vercel-000000?style=for-the-badge&logo=vercel&logoColor=white) | Git/GitHub๋กœ ๋ฒ„์ „ ๊ด€๋ฆฌ ๋ฐ ํ˜‘์—…์„ ์ง„ํ–‰ํ•˜๊ณ , Vercel๋กœ ๋ฐฐํฌ ์ž๋™ํ™” ๋ฐ CI/CD ๊ณผ์ •์„ ๊ฐ„์†Œํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค. | +| **Package Manager** | ![npm](https://img.shields.io/badge/npm-CB3837?style=for-the-badge&logo=npm&logoColor=white) | ํ‘œ์ค€ ํŒจํ‚ค์ง€ ๋งค๋‹ˆ์ €(npm)๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์˜์กด์„ฑ ์„ค์น˜ ๋ฐ ๊ด€๋ฆฌ๋ฅผ ์ˆ˜ํ–‰ํ–ˆ์Šต๋‹ˆ๋‹ค. | +| **Validation** | ![Zod](https://img.shields.io/badge/Zod-3E67B1?style=for-the-badge&logo=zod&logoColor=white) | Zod๋กœ API ์‘๋‹ต ๋ฐ ํผ ์ž…๋ ฅ ๊ฐ’์„ ์Šคํ‚ค๋งˆ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ฒ€์ฆํ•ด ๋Ÿฐํƒ€์ž„ ์˜ค๋ฅ˜๋ฅผ ์ค„์ด๊ณ  ํƒ€์ž… ์•ˆ์ •์„ฑ์„ ๊ฐ•ํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค. | +| **Collaboration** | ![Notion](https://img.shields.io/badge/Notion-000000?style=for-the-badge&logo=notion&logoColor=white) ![Figma](https://img.shields.io/badge/Figma-F24E1E?style=for-the-badge&logo=figma&logoColor=white) ![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white) | Notion์„ ํ†ตํ•œ ๋ฌธ์„œํ™”์™€ ํ”„๋กœ์ ํŠธ ์ž๋ฃŒ ๊ด€๋ฆฌ, Figma ๊ธฐ๋ฐ˜ ๋””์ž์ธ ํ˜‘์—…, Discord๋ฅผ ํ™œ์šฉํ•œ ์‹ค์‹œ๊ฐ„ ์ปค๋ฎค๋‹ˆ์ผ€์ด์…˜์œผ๋กœ ํŒ€ ํ˜‘์—… ํšจ์œจ์„ ๋†’์˜€์Šต๋‹ˆ๋‹ค. | -* main: ์ตœ์ข…์ ์œผ๋กœ ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ฐฐํฌ๋˜๋Š” ๊ฐ€์žฅ ์•ˆ์ •์ ์ธ ๋ฒ„์ „ ๋ธŒ๋žœ์น˜ -* develop: ๋‹ค์Œ ์ถœ์‹œ ๋ฒ„์ „์„ ๊ฐœ๋ฐœํ•˜๋Š” ์ค‘์‹ฌ ๋ธŒ๋žœ์น˜. ๊ธฐ๋Šฅ ๊ฐœ๋ฐœ ์™„๋ฃŒ ํ›„ feature ๋ธŒ๋žœ์น˜๋“ค์ด ๋ณ‘ํ•ฉ -* feature: ๊ธฐ๋Šฅ ๊ฐœ๋ฐœ์šฉ ๋ธŒ๋žœ์น˜. develop์—์„œ ๋ถ„๊ธฐํ•˜์—ฌ ์ž‘์—… --- +## ๐Ÿ“Ž ์ปจ๋ฒค์…˜ (Conventions) + +- ๐Ÿƒ [Commit Convention](https://www.notion.so/Commit-Convention-305c82f125c980898111df6891b25578?source=copy_link) +- ๐Ÿชต [Branch Convention](https://www.notion.so/Branch-Convention-305c82f125c980afaecfdc5b00ad1f71?source=copy_link) +- ๐Ÿ’ป [Coding Convention](https://www.notion.so/Coding-Convention-2dac82f125c980a8810bca8fadbe8b5f?source=copy_link) +- ๐Ÿ“ข [API Convention](https://www.notion.so/API-Convention-2e0c82f125c9805bb0c3c22fda770978?source=copy_link) +- ๐Ÿ“Œ [Issue Convention](https://www.notion.so/Issue-Convention-305c82f125c980029b7ce00b73962f66?source=copy_link) +- โœ… [PR Convention](https://www.notion.so/PR-Convention-305c82f125c9809e98ebe702534bf18a?source=copy_link) +- ๐Ÿ“ [Folder Structure Convention](https://www.notion.so/Folder-Structure-Convention-2d4c82f125c98112bd64d946da9d2f34?source=copy_link) -## ๐Ÿ“Œ ๋ธŒ๋žœ์น˜ ๊ทœ์น™ ๋ฐ ๋„ค์ด๋ฐ (Branch Rules & Naming) +--- -* ๋ชจ๋“  ๊ธฐ๋Šฅ ๊ฐœ๋ฐœ์€ feature ๋ธŒ๋žœ์น˜์—์„œ ์‹œ์ž‘ -* ์ž‘์—… ์‹œ์ž‘ ์ „, ํ•ญ์ƒ ์ตœ์‹  develop ๋‚ด์šฉ ๋ฐ›์•„์˜ค๊ธฐ (git pull origin develop) -* ์ž‘์—… ์™„๋ฃŒ ํ›„, develop์œผ๋กœ Pull Request(PR) ์ƒ์„ฑ -* PR์— Reviewer(๋ฉ˜์…˜) ์ง€์ • ์ดํ›„ ๋จธ์ง€ -* ๋ธŒ๋žœ์น˜ ์ด๋ฆ„ ํ˜•์‹: feature/์ด์Šˆ๋ฒˆํ˜ธ-๊ธฐ๋Šฅ๋ช… -* ์˜ˆ์‹œ: feature/1-login-ui +## ๐Ÿค ๊ทธ๋ผ์šด๋“œ ๋ฃฐ (Ground Rules) +- **์—ฐ๋ฝ ์ž์ฃผ ํ™•์ธํ•˜๊ธฐ** + - ๋””์Šค์ฝ”๋“œ/์นด์นด์˜คํ†ก ์•Œ๋ฆผ์„ ์ž์ฃผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + +- **์ ๊ทน์ ์œผ๋กœ ์˜๊ฒฌ ๊ณต์œ ํ•˜๊ธฐ** + - ๋ง‰ํžˆ๋Š” ๋ถ€๋ถ„์ด๋‚˜ ๋ณ€๊ฒฝ ์‚ฌํ•ญ์ด ์ƒ๊ธฐ๋ฉด ๋น ๋ฅด๊ฒŒ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค. + +- **PR Merge ๊ทœ์น™** + - ๊ธฐ๋ณธ์ ์œผ๋กœ ์ „์› Approve ํ›„ Mergeํ•˜๋ฉฐ, PR ์ž‘์„ฑ์ž๊ฐ€ ๋ธŒ๋žœ์น˜๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + - ๋‹จ, ์—ฌํ–‰ ๋“ฑ ๊ฐœ์ธ ์ผ์ •์œผ๋กœ ํ™•์ธ์ด ์–ด๋ ค์šด ๊ฒฝ์šฐ, ํ•ด๋‹น ์ธ์›์„ ์ œ์™ธํ•œ ํŒ€์›์˜ Approve๋กœ Merge ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + +- **๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์„ค์น˜ ๊ณต์œ ** + - ์ž‘์—… ์ค‘ ์ƒˆ๋กœ์šด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์„ค์น˜ํ•œ ๊ฒฝ์šฐ, ๋””์Šค์ฝ”๋“œ ์Šค๋ ˆ๋“œ์— ๋Œ“๊ธ€๋กœ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค. + +- **Figma ์ˆ˜์น˜ ๊ทœ์น™** + - ๋””์ž์ด๋„ˆ๋‹˜๊ป˜์„œ ๋ณ„๋„๋กœ ์•ˆ๋‚ดํ•œ ๊ฒฝ์šฐ๋ฅผ ์ œ์™ธํ•˜๊ณ , ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด 4์˜ ๋ฐฐ์ˆ˜๋กœ ์Šค๋ƒ…ํ•˜์—ฌ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. + --- -## ๐ŸŽฏ ์ปค๋ฐ‹ ์ปจ๋ฒค์…˜ (Commit Convention) - -### ์ฃผ์˜ ์‚ฌํ•ญ -* type์€ ์†Œ๋ฌธ์ž๋งŒ ์‚ฌ์šฉ (feat, fix, refactor, docs, style, test, chore) -* subject๋Š” ๋ชจ๋‘ ํ˜„์žฌํ˜• ๋™์‚ฌ - -### ๐Ÿ“‹ ํƒ€์ž… ๋ชฉ๋ก - -| type | ์„ค๋ช… | -| :--- | :--- | -| start | ์ƒˆ๋กœ์šด ํ”„๋กœ์ ํŠธ๋ฅผ ์‹œ์ž‘ํ•  ๋•Œ | -| feat | ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•  ๋•Œ | -| fix | ๋ฒ„๊ทธ๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ | -| design | CSS ๋“ฑ ์‚ฌ์šฉ์ž UI ๋””์ž์ธ์„ ๋ณ€๊ฒฝํ•  ๋•Œ | -| refactor | ๊ธฐ๋Šฅ ๋ณ€๊ฒฝ ์—†์ด ์ฝ”๋“œ๋ฅผ ๋ฆฌํŒฉํ† ๋งํ•  ๋•Œ | -| settings | ์„ค์ • ํŒŒ์ผ์„ ๋ณ€๊ฒฝํ•  ๋•Œ | -| comment | ํ•„์š”ํ•œ ์ฃผ์„์„ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ๋ณ€๊ฒฝํ•  ๋•Œ | -| dependency/Plugin | ์˜์กด์„ฑ/ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ์ถ”๊ฐ€ํ•  ๋•Œ | -| docs | README.md ๋“ฑ ๋ฌธ์„œ๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ | -| merge | ๋ธŒ๋žœ์น˜๋ฅผ ๋ณ‘ํ•ฉํ•  ๋•Œ | -| deploy | ๋นŒ๋“œ ๋ฐ ๋ฐฐํฌ ๊ด€๋ จ ์ž‘์—…์„ ํ•  ๋•Œ | -| rename | ํŒŒ์ผ ํ˜น์€ ํด๋”๋ช…์„ ์ˆ˜์ •ํ•˜๊ฑฐ๋‚˜ ์˜ฎ๊ธธ ๋•Œ | -| remove | ํŒŒ์ผ์„ ์‚ญ์ œํ•˜๋Š” ์ž‘์—…๋งŒ ์ˆ˜ํ–‰ํ–ˆ์„ ๋•Œ | -| revert | ์ด์ „ ๋ฒ„์ „์œผ๋กœ ๋กค๋ฐฑํ•  ๋•Œ | - -### โœจ ์˜ˆ์‹œ -* feat: ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€ -* fix: ๊ฐ€๋ ค์ง ํ˜„์ƒ ํ•ด๊ฒฐ +## ๐Ÿ“ฐ ์•„ํ‹ฐํด ๋ชจ์Œ (Articles) + +| ์ด๋ฆ„ | ์•„ํ‹ฐํด ์ œ๋ชฉ | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ๐Ÿ’ ๋ฐ•์œ ๋ฏผ | [์ƒํƒœ ๊ธฐ๋ฐ˜ ์ธํ„ฐ๋ž™์…˜ ๊ตฌํ˜„๊ธฐ](https://velog.io/@waldls/Frontend-%EC%83%81%ED%83%9C-%EA%B8%B0%EB%B0%98-%EC%9D%B8%ED%84%B0%EB%9E%99%EC%85%98-%EA%B5%AC%ED%98%84%EA%B8%B0) | +| ๐Ÿ€ ์ด์„ ์šฐ | [Device Life Auth ์‹œ์Šคํ…œ ๊ตฌํ˜„ ํšŒ๊ณ ](https://www.notion.so/Device-Life-Auth-305564e055eb8007a616fde7ddb853d7?source=copy_link) | diff --git a/build.sh b/build.sh new file mode 100644 index 00000000..772d1617 --- /dev/null +++ b/build.sh @@ -0,0 +1,4 @@ +cd ../ +mkdir output +cp -R ./Frontend/* ./output +cp -R ./output ./Frontend/ diff --git a/index.html b/index.html index 42fcf48c..fc71465b 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,26 @@ - + - DeviceLife + Device Life + + + + + + + + + + + + + + + + +
diff --git a/package-lock.json b/package-lock.json index 0d431086..2fddd212 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,26 @@ "name": "devicelife", "version": "0.0.0", "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@react-oauth/google": "^0.13.4", + "@tanstack/react-query": "^5.90.20", + "@tanstack/react-query-devtools": "^5.91.2", + "@types/react-window": "^1.8.8", + "axios": "^1.13.2", + "clsx": "^2.1.1", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-hook-form": "^7.71.1", + "react-router-dom": "^7.12.0", + "react-spinners": "^0.17.0", + "react-window": "^2.2.6", + "tailwind-merge": "^3.4.0", + "zod": "^4.3.5", + "zustand": "^5.0.10" }, "devDependencies": { "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", "@types/node": "^24.10.4", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -24,9 +39,11 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "prettier": "3.7.4", + "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vite-plugin-svgr": "^4.5.0" } }, "node_modules/@babel/code-frame": { @@ -911,6 +928,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1026,6 +1055,16 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@react-oauth/google": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.4.tgz", + "integrity": "sha512-hGKyNEH+/PK8M0sFEuo3MAEk0txtHpgs94tDQit+s2LXg7b6z53NtzHfqDvoB2X8O6lGB+FRg80hY//X6hfD+w==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -1033,6 +1072,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.54.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", @@ -1241,12 +1303,501 @@ "optional": true, "os": [ "linux" - ] + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", - "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", "cpu": [ "x64" ], @@ -1255,12 +1806,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", - "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", "cpu": [ "x64" ], @@ -1269,26 +1823,45 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", - "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "openharmony" - ] + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", - "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", "cpu": [ "arm64" ], @@ -1297,26 +1870,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", - "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", - "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", "cpu": [ "x64" ], @@ -1325,21 +1887,79 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", - "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", - "cpu": [ - "x64" ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.92.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz", + "integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", + "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz", + "integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.92.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.90.14", + "react": "^18 || ^19" + } }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1415,7 +2035,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -1432,6 +2051,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.50.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz", @@ -1787,6 +2415,23 @@ "dev": true, "license": "Python-2.0" }, + "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/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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1850,6 +2495,19 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1860,6 +2518,19 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001761", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", @@ -1898,6 +2569,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1918,6 +2598,18 @@ "dev": true, "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": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1932,6 +2624,46 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1951,40 +2683,165 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "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": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "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": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "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": { - "ms": "^2.1.3" + "es-errors": "^1.3.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">= 0.4" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, - "license": "ISC" + "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": { "version": "0.27.2", @@ -2274,6 +3131,13 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2381,6 +3245,42 @@ "dev": true, "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/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2396,6 +3296,15 @@ "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": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2406,6 +3315,43 @@ "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-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": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2432,6 +3378,25 @@ "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": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2442,6 +3407,45 @@ "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/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2496,6 +3500,13 @@ "node": ">=0.8.19" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2526,6 +3537,16 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2559,64 +3580,339 @@ "node": ">=6" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2640,6 +3936,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2650,6 +3956,46 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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/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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2696,6 +4042,17 @@ "dev": true, "license": "MIT" }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -2766,6 +4123,25 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2786,6 +4162,16 @@ "node": ">=8" } }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2876,6 +4262,12 @@ "node": ">=6.0.0" } }, + "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": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2901,6 +4293,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2908,6 +4301,23 @@ "react": "^19.2.3" } }, + "node_modules/react-hook-form": { + "version": "7.71.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", + "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -2918,6 +4328,64 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", + "license": "MIT", + "dependencies": { + "react-router": "7.12.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-spinners": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.17.0.tgz", + "integrity": "sha512-L/8HTylaBmIWwQzIjMq+0vyaRXuoAevzWoD35wKpNTxxtYXWZp+xtgkfD7Y4WItuX0YvdxMPU79+7VhhmbmuTQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-window": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.6.tgz", + "integrity": "sha512-v89O08xRdpCaEuf380B39D1C/0KgUDZA59xft6SVAjzjz/xQxSyXrgDWHymIsYI6TMrqE8WO+G0/PB9AGE8VNA==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2986,6 +4454,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3009,6 +4483,17 @@ "node": ">=8" } }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3045,6 +4530,13 @@ "node": ">=8" } }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -3061,6 +4553,37 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3091,6 +4614,13 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3267,6 +4797,21 @@ } } }, + "node_modules/vite-plugin-svgr": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.5.0.tgz", + "integrity": "sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.2.0", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0" + }, + "peerDependencies": { + "vite": ">=2.6.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3314,10 +4859,9 @@ } }, "node_modules/zod": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", - "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", - "dev": true, + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", "peer": true, "funding": { @@ -3336,6 +4880,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz", + "integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index c11f2b4e..49afd26e 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,26 @@ "preview": "vite preview" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@react-oauth/google": "^0.13.4", + "@tanstack/react-query": "^5.90.20", + "@tanstack/react-query-devtools": "^5.91.2", + "@types/react-window": "^1.8.8", + "axios": "^1.13.2", + "clsx": "^2.1.1", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-hook-form": "^7.71.1", + "react-router-dom": "^7.12.0", + "react-spinners": "^0.17.0", + "react-window": "^2.2.6", + "tailwind-merge": "^3.4.0", + "zod": "^4.3.5", + "zustand": "^5.0.10" }, "devDependencies": { "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", "@types/node": "^24.10.4", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -26,8 +41,10 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "prettier": "3.7.4", + "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vite-plugin-svgr": "^4.5.0" } } diff --git a/public/devicelife.svg b/public/devicelife.svg new file mode 100644 index 00000000..62919200 --- /dev/null +++ b/public/devicelife.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/og-thumbnail.png b/public/og-thumbnail.png new file mode 100644 index 00000000..dff78e70 Binary files /dev/null and b/public/og-thumbnail.png differ diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App.css b/src/App.css deleted file mode 100644 index e69de29b..00000000 diff --git a/src/App.tsx b/src/App.tsx index 729b1ea3..e11b017d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,8 @@ +import { RouterProvider } from 'react-router-dom'; +import { router } from '@/routes/router'; + const App = () => { - return
App
; + return ; }; export default App; diff --git a/src/apis/auth/postJoin.ts b/src/apis/auth/postJoin.ts new file mode 100644 index 00000000..c3178748 --- /dev/null +++ b/src/apis/auth/postJoin.ts @@ -0,0 +1,14 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { SignupRequest, SignupResponse } from '@/types/auth/signup'; +import { useMutation } from '@tanstack/react-query'; + +export const postJoin = async (payload: SignupRequest): Promise => { + const { data } = await axiosInstance.post('/api/auth/join', payload); + return data; +}; + +export const usePostJoin = () => { + return useMutation({ + mutationFn: postJoin, + }); +}; diff --git a/src/apis/auth/postJoinEmail.ts b/src/apis/auth/postJoinEmail.ts new file mode 100644 index 00000000..8588ecf3 --- /dev/null +++ b/src/apis/auth/postJoinEmail.ts @@ -0,0 +1,22 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { + EmailDuplicateRequest, + EmailDuplicateResponse, +} from '@/types/auth/signup'; +import { useMutation } from '@tanstack/react-query'; + +export const postJoinEmail = async ( + payload: EmailDuplicateRequest +): Promise => { + const { data } = await axiosInstance.post( + '/api/auth/join/email', + payload + ); + return data; +}; + +export const usePostJoinEmail = () => { + return useMutation({ + mutationFn: postJoinEmail, + }); +}; diff --git a/src/apis/auth/postLogin.ts b/src/apis/auth/postLogin.ts new file mode 100644 index 00000000..d02a5416 --- /dev/null +++ b/src/apis/auth/postLogin.ts @@ -0,0 +1,15 @@ +import { cookieAxiosInstance } from '@/apis/axios/cookieAxios'; +import type { LoginRequest, LoginResponse } from '@/types/auth/login'; +import { useMutation } from '@tanstack/react-query'; + +export const postLogin = async (payload: LoginRequest): Promise => { + // refreshToken์€ httpOnly ์ฟ ํ‚ค๋กœ ์ž๋™ ์ˆ˜์‹ ๋จ + const { data } = await cookieAxiosInstance.post('/api/auth/login', payload); + return data; +}; + +export const usePostLogin = () => { + return useMutation({ + mutationFn: postLogin, + }); +}; diff --git a/src/apis/auth/postLogout.ts b/src/apis/auth/postLogout.ts new file mode 100644 index 00000000..04194d48 --- /dev/null +++ b/src/apis/auth/postLogout.ts @@ -0,0 +1,18 @@ +import { cookieAxiosInstance } from '@/apis/axios/cookieAxios'; +import type { LogoutResponse } from '@/types/auth/logout'; +import { useMutation } from '@tanstack/react-query'; + +export const postLogout = async (): Promise => { + // refreshToken์€ httpOnly ์ฟ ํ‚ค๋กœ ์ž๋™ ์ „์†ก๋จ + const { data } = await cookieAxiosInstance.post( + '/api/auth/logout', + {} + ); + return data; +}; + +export const usePostLogout = () => { + return useMutation({ + mutationFn: postLogout, + }); +}; diff --git a/src/apis/auth/postRefresh.ts b/src/apis/auth/postRefresh.ts new file mode 100644 index 00000000..0ea381ba --- /dev/null +++ b/src/apis/auth/postRefresh.ts @@ -0,0 +1,18 @@ +import { cookieAxiosInstance } from '@/apis/axios/cookieAxios'; +import type { RefreshTokenResponse } from '@/types/auth/refresh'; +import { useMutation } from '@tanstack/react-query'; + +export const postRefresh = async (): Promise => { + // refreshToken์€ httpOnly ์ฟ ํ‚ค๋กœ ์ž๋™ ์ „์†ก๋จ + const { data } = await cookieAxiosInstance.post( + '/api/auth/refresh', + {} + ); + return data; +}; + +export const usePostRefresh = () => { + return useMutation({ + mutationFn: postRefresh, + }); +}; diff --git a/src/apis/axios/axios.ts b/src/apis/axios/axios.ts new file mode 100644 index 00000000..5c3371e6 --- /dev/null +++ b/src/apis/axios/axios.ts @@ -0,0 +1,17 @@ +// ๋ฉ”์ธ axios ์ธ์Šคํ„ด์Šค ํŒŒ์ผ + +import axios from 'axios'; +import { setupRequestInterceptor, setupResponseInterceptor } from '@/apis/axios/interceptors'; + +const baseURL = import.meta.env.VITE_SERVER_API_URL; + +export const axiosInstance = axios.create({ + baseURL, + withCredentials: false, // ๊ธฐ๋ณธ์ ์œผ๋กœ ์ฟ ํ‚ค ๋ฏธํฌํ•จ (์ฟ ํ‚ค ํ•„์š”ํ•œ API๋Š” cookieAxiosInstance ์‚ฌ์šฉ) +}); + +// ์š”์ฒญ ์ธํ„ฐ์…‰ํ„ฐ ์„ค์ • +setupRequestInterceptor(axiosInstance); + +// ์‘๋‹ต ์ธํ„ฐ์…‰ํ„ฐ ์„ค์ • +setupResponseInterceptor(axiosInstance); \ No newline at end of file diff --git a/src/apis/axios/cookieAxios.ts b/src/apis/axios/cookieAxios.ts new file mode 100644 index 00000000..4230c401 --- /dev/null +++ b/src/apis/axios/cookieAxios.ts @@ -0,0 +1,10 @@ +// ์ฟ ํ‚ค๊ฐ€ ํ•„์š”ํ•œ API ์ „์šฉ axios ์ธ์Šคํ„ด์Šค (์ธํ„ฐ์…‰ํ„ฐ ์—†์Œ) + +import axios from 'axios'; + +const baseURL = import.meta.env.VITE_SERVER_API_URL; + +export const cookieAxiosInstance = axios.create({ + baseURL, + withCredentials: true, // ์ฟ ํ‚ค/์„ธ์…˜ ์ž๋™ ํฌํ•จ +}); diff --git a/src/apis/axios/interceptors.ts b/src/apis/axios/interceptors.ts new file mode 100644 index 00000000..d96e340c --- /dev/null +++ b/src/apis/axios/interceptors.ts @@ -0,0 +1,151 @@ +/* +์š”์ฒญ ๋ฐ ์‘๋‹ต ์ธํ„ฐ์…‰ํ„ฐ + * + * [401 ์—๋Ÿฌ ์ฒ˜๋ฆฌ ํ๋ฆ„] + * 1. 401 ์—๋Ÿฌ ๋ฐœ์ƒ + * 2. ํ† ํฐ ๊ฐฑ์‹  ์‹œ๋„ (refreshToken์€ httpOnly ์ฟ ํ‚ค๋กœ ์ž๋™ ์ „์†ก๋จ) + * 3. ํ† ํฐ ๊ฐฑ์‹  (refreshPromise) + * - ์„ฑ๊ณต: ์ƒˆ accessToken์œผ๋กœ ์›๋ž˜ ์š”์ฒญ ์žฌ์‹œ๋„ + * - ์‹คํŒจ: ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ (refreshToken ๋งŒ๋ฃŒ ๋˜๋Š” ์—†์Œ) + * 4. ๋™์‹œ ์š”์ฒญ ์ฒ˜๋ฆฌ: ์—ฌ๋Ÿฌ ์š”์ฒญ์ด ๋™์‹œ์— 401์„ ๋ฐ›์œผ๋ฉด refreshPromise๋ฅผ ๊ณต์œ ํ•˜์—ฌ ํ† ํฐ ๊ฐฑ์‹ ์€ 1๋ฒˆ๋งŒ ์ˆ˜ํ–‰ +*/ +import type { InternalAxiosRequestConfig, AxiosInstance } from 'axios'; +import { + getAccessToken, + setAccessToken, + clearAccessToken, +} from '@/utils/authStorage'; +import { postRefresh } from '@/apis/auth/postRefresh'; +import { setAuthorizationHeader } from '@/utils/setAuthorizationHeader'; +import { ROUTES } from '@/constants/routes'; + + +// ๋ชจ๋“ˆ ๋ ˆ๋ฒจ ์ƒํƒœ + +// ํ† ํฐ ๊ฐฑ์‹  ์ค‘์ธ Promise (๋™์‹œ ์š”์ฒญ ์‹œ ๊ณต์œ ) +let refreshPromise: Promise | null = null; +// ์ค‘๋ณต ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๋ฐฉ์ง€ ํ”Œ๋ž˜๊ทธ +let isRedirectingToLogin = false; + + +// ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜ + +/* ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ํ•จ์ˆ˜ (์ค‘๋ณต ๋ฐฉ์ง€) + - ์—ฌ๋Ÿฌ ์š”์ฒญ์ด ๋™์‹œ์— ์‹คํŒจํ•ด๋„ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ๋Š” 1๋ฒˆ๋งŒ ์‹คํ–‰ + - ๋น„๋กœ๊ทธ์ธ ์ƒํƒœ์—์„œ 401 ๋ฐœ์ƒ ์‹œ์—๋„ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ์ˆ˜ํ–‰ +*/ +const redirectToLoginOnce = () => { + if (isRedirectingToLogin) return; + + isRedirectingToLogin = true; + clearAccessToken(); + window.location.href = ROUTES.auth.login; +}; + + +// ์š”์ฒญ ์ธํ„ฐ์…‰ํ„ฐ +/* + * ์š”์ฒญ ์ธํ„ฐ์…‰ํ„ฐ ์„ค์ • + * - ๋งค ์š”์ฒญ ์ „์— accessToken์„ Authorization ํ—ค๋”์— ์ถ”๊ฐ€ + * - ์žฌ๋กœ๊ทธ์ธ ํ›„ ํ”Œ๋ž˜๊ทธ ๋ฆฌ์…‹ +*/ +export const setupRequestInterceptor = (instance: AxiosInstance) => { + instance.interceptors.request.use((config) => { + const accessToken = getAccessToken(); + + // ์žฌ๋กœ๊ทธ์ธ ํ›„ ํ† ํฐ์ด ๋‹ค์‹œ ์ƒ๊ธฐ๋ฉด ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ํ”Œ๋ž˜๊ทธ ๋ฆฌ์…‹ + if (accessToken && isRedirectingToLogin) { + isRedirectingToLogin = false; + } + + if (accessToken) { + setAuthorizationHeader(config, accessToken); + } + + return config; + }); +}; + + +// ์‘๋‹ต ์ธํ„ฐ์…‰ํ„ฐ +/* + * ์‘๋‹ต ์ธํ„ฐ์…‰ํ„ฐ ์„ค์ • + * - 401 ์—๋Ÿฌ ์‹œ ํ† ํฐ ๊ฐฑ์‹  ํ›„ ์›๋ž˜ ์š”์ฒญ ์žฌ์‹œ๋„ + * - ๋™์‹œ ์š”์ฒญ ์ฒ˜๋ฆฌ: refreshPromise๋ฅผ ๊ณต์œ ํ•˜์—ฌ ํ† ํฐ ๊ฐฑ์‹ ์€ 1๋ฒˆ๋งŒ ์ˆ˜ํ–‰ + */ +export const setupResponseInterceptor = (instance: AxiosInstance) => { + instance.interceptors.response.use( + (response) => response, + async (error) => { + + // [1] ์‚ฌ์ „ ๊ฒ€์ฆ: 401 ์—๋Ÿฌ๊ฐ€ ์•„๋‹ˆ๊ฑฐ๋‚˜ ์žฌ์‹œ๋„ ๋ถˆ๊ฐ€๋Šฅํ•œ ๊ฒฝ์šฐ early return + if (!error.config) { + return Promise.reject(error); + } + + const originalRequest = error.config as InternalAxiosRequestConfig & { + _retry?: boolean; + }; + + if (error.response?.status !== 401) { + return Promise.reject(error); + } + + if (originalRequest._retry) { + return Promise.reject(error); + } + + // ์žฌ์‹œ๋„ ํ”Œ๋ž˜๊ทธ ์„ค์ • (๋ฌดํ•œ๋ฃจํ”„ ๋ฐฉ์ง€) + originalRequest._retry = true; + + // [2] ์ด๋ฏธ ๋‹ค๋ฅธ ์š”์ฒญ์ด ํ† ํฐ ๊ฐฑ์‹  ์ค‘์ด๋ฉด ๊ทธ ๊ฒฐ๊ณผ๋ฅผ ๊ธฐ๋‹ค๋ฆผ + if (refreshPromise) { + try { + const newToken = await refreshPromise; + setAuthorizationHeader(originalRequest, newToken); + return instance(originalRequest); + } catch (refreshError) { + redirectToLoginOnce(); + return Promise.reject(refreshError); + } + } + + + // [3] ํ† ํฐ ๊ฐฑ์‹  Promise ์ƒ์„ฑ ๋ฐ ์‹คํ–‰ + // - try: ํ† ํฐ ๊ฐฑ์‹  API ํ˜ธ์ถœ (refreshToken์€ httpOnly ์ฟ ํ‚ค๋กœ ์ž๋™ ์ „์†ก) โ†’ ์„ฑ๊ณต ์‹œ ์ƒˆ accessToken ๋ฐ˜ํ™˜ + // - catch: ์‹คํŒจ ์‹œ ๋กœ๊ทธ์ธ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ โ†’ ์—๋Ÿฌ re-throw (์™ธ๋ถ€ catch๋กœ ์ „ํŒŒ) + // - finally: ์„ฑ๊ณต/์‹คํŒจ ๊ด€๊ณ„์—†์ด refreshPromise ์ดˆ๊ธฐํ™” + refreshPromise = (async () => { + try { + const data = await postRefresh(); + + if (!data?.result?.accessToken) { + throw new Error('ํ† ํฐ ์žฌ๋ฐœ๊ธ‰ ์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'); + } + + // ์ƒˆ accessToken ์ €์žฅ (refreshToken์€ httpOnly ์ฟ ํ‚ค๋กœ ์„œ๋ฒ„์—์„œ ๊ด€๋ฆฌ) + setAccessToken(data.result.accessToken); + + return data.result.accessToken; + } catch (refreshError) { + redirectToLoginOnce(); + throw refreshError; // ์™ธ๋ถ€ catch๋กœ ์ „ํŒŒ + } finally { + refreshPromise = null; // ํ•ญ์ƒ ์ดˆ๊ธฐํ™” (์„ฑ๊ณต/์‹คํŒจ ๋ฌด๊ด€) + } + })(); + + // [4] ํ† ํฐ ๊ฐฑ์‹  ๊ฒฐ๊ณผ์— ๋”ฐ๋ผ ์›๋ž˜ ์š”์ฒญ ์žฌ์‹œ๋„ ๋˜๋Š” ์—๋Ÿฌ ๋ฐ˜ํ™˜ + // - ์„ฑ๊ณต: ์ƒˆ ํ† ํฐ์œผ๋กœ ์›๋ž˜ ์š”์ฒญ ์žฌ์‹œ๋„ + // - ์‹คํŒจ: [4]์˜ catch์—์„œ ์ด๋ฏธ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ์ฒ˜๋ฆฌ๋จ, ์—ฌ๊ธฐ์„œ๋Š” ์—๋Ÿฌ๋งŒ ์ „๋‹ฌ + try { + const newToken = await refreshPromise; + setAuthorizationHeader(originalRequest, newToken); + return instance(originalRequest); + } catch (refreshError) { + return Promise.reject(refreshError); + } + } + ); +}; + diff --git a/src/apis/combo/deleteCombo.ts b/src/apis/combo/deleteCombo.ts new file mode 100644 index 00000000..720341e6 --- /dev/null +++ b/src/apis/combo/deleteCombo.ts @@ -0,0 +1,23 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { DeleteComboResponse } from '@/types/combo/combo'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +// ์กฐํ•ฉ ์‚ญ์ œ +export const deleteCombo = async (comboId: number): Promise => { + const { data } = await axiosInstance.delete( + `/api/combos/${comboId}` + ); + return data; +}; + +export const useDeleteCombo = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (comboId: number) => deleteCombo(comboId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [queryKey.COMBOS] }); + }, + }); +}; diff --git a/src/apis/combo/deleteComboDevice.ts b/src/apis/combo/deleteComboDevice.ts new file mode 100644 index 00000000..815d7484 --- /dev/null +++ b/src/apis/combo/deleteComboDevice.ts @@ -0,0 +1,33 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { DeleteComboDeviceResponse } from '@/types/combo/combo'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; +import { usePollComboEvaluation } from '@/hooks/usePollComboEvaluation'; + +// ์กฐํ•ฉ์—์„œ ๊ธฐ๊ธฐ ์‚ญ์ œ +export const deleteComboDevice = async ( + comboId: number, + deviceId: number +): Promise => { + const { data } = await axiosInstance.delete( + `/api/combos/${comboId}/devices/${deviceId}` + ); + return data; +}; + +export const useDeleteComboDevice = () => { + const queryClient = useQueryClient(); + const { poll } = usePollComboEvaluation(); + + return useMutation({ + mutationFn: ({ comboId, deviceId }: { comboId: number; deviceId: number }) => + deleteComboDevice(comboId, deviceId), + onSuccess: (_data, variables) => { + // ์กฐํ•ฉ ๋ชฉ๋ก๊ณผ ์ƒ์„ธ ์ •๋ณด ์บ์‹œ ๋ฌดํšจํ™” + queryClient.invalidateQueries({ queryKey: [queryKey.COMBOS] }); + queryClient.invalidateQueries({ queryKey: [queryKey.COMBO_DETAIL] }); + // ์กฐํ•ฉ ํ‰๊ฐ€ ํด๋ง ์‹œ์ž‘ + poll(variables.comboId); + }, + }); +}; diff --git a/src/apis/combo/getComboEvaluation.ts b/src/apis/combo/getComboEvaluation.ts new file mode 100644 index 00000000..f55be82a --- /dev/null +++ b/src/apis/combo/getComboEvaluation.ts @@ -0,0 +1,24 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { GetComboEvaluationResponse, ComboEvaluationResult } from '@/types/combo/evaluation'; +import { useQuery } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +// ์กฐํ•ฉ ํ‰๊ฐ€ ์ ์ˆ˜ ์กฐํšŒ API +export const getComboEvaluation = async (comboId: number): Promise => { + const { data } = await axiosInstance.get( + `/api/combos/${comboId}/evaluation` + ); + return data.result!; +}; + +// ์กฐํ•ฉ ํ‰๊ฐ€ ์กฐํšŒ ํ›… (์บ์‹œ ๊ตฌ๋… + ์ดˆ๊ธฐ fetch) +export const useComboEvaluation = (comboId: number | undefined) => { + return useQuery({ + queryKey: [queryKey.COMBO_EVALUATION, comboId], + queryFn: () => getComboEvaluation(comboId!), + enabled: !!comboId, + staleTime: Infinity, // ํด๋ง์ด setQueryData๋กœ ๊ฐฑ์‹ ํ•˜๋ฏ€๋กœ ์ž๋™ refetch ๋ถˆํ•„์š” + retry: false, // 404(EVAL_4041)์ผ ๋•Œ ๋ฌดํ•œ ์žฌ์‹œ๋„ ๋ฐฉ์ง€ + }); +}; + diff --git a/src/apis/combo/getComboId.ts b/src/apis/combo/getComboId.ts new file mode 100644 index 00000000..afb13dc5 --- /dev/null +++ b/src/apis/combo/getComboId.ts @@ -0,0 +1,18 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { GetComboResponse, GetComboResult } from '@/types/combo/combo'; +import { useQuery } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +// ์กฐํ•ฉ ์ƒ์„ธ ์กฐํšŒ +export const getCombo = async (comboId: number): Promise => { + const { data } = await axiosInstance.get(`/api/combos/${comboId}`); + return data.result!; +}; + +export const useGetCombo = (comboId: number | null) => { + return useQuery({ + queryKey: [queryKey.COMBO_DETAIL, comboId], + queryFn: () => getCombo(comboId!), + enabled: comboId !== null, + }); +}; diff --git a/src/apis/combo/getCombos.ts b/src/apis/combo/getCombos.ts new file mode 100644 index 00000000..6f5c47bd --- /dev/null +++ b/src/apis/combo/getCombos.ts @@ -0,0 +1,22 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { GetCombosResponse, GetCombosResult } from '@/types/combo/combo'; +import { useQuery } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; +import { useAuth } from '@/hooks/useAuth'; + +// ์กฐํ•ฉ ๋ชฉ๋ก ์กฐํšŒ +export const getCombos = async (): Promise => { + const { data } = await axiosInstance.get('/api/combos'); + return data.result ?? []; +}; + +export const useGetCombos = () => { + const { isLoggedIn } = useAuth(); + + return useQuery({ + + queryKey: [queryKey.COMBOS], + queryFn: getCombos, + enabled: isLoggedIn + }); +}; diff --git a/src/apis/combo/postComboDevices.ts b/src/apis/combo/postComboDevices.ts new file mode 100644 index 00000000..2a6e805d --- /dev/null +++ b/src/apis/combo/postComboDevices.ts @@ -0,0 +1,38 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { + PostComboDeviceRequest, + PostComboDeviceResponse, +} from '@/types/combo/combo'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; +import { usePollComboEvaluation } from '@/hooks/usePollComboEvaluation'; + +// ์กฐํ•ฉ์— ๊ธฐ๊ธฐ ์ถ”๊ฐ€ +export const postComboDevice = async ( + comboId: number, + payload: PostComboDeviceRequest +): Promise => { + const { data } = await axiosInstance.post( + `/api/combos/${comboId}/devices`, + payload + ); + return data; +}; + +export const usePostComboDevice = () => { + const queryClient = useQueryClient(); + const { poll } = usePollComboEvaluation(); + + return useMutation({ + mutationFn: ({ comboId, deviceId }: { comboId: number; deviceId: number }) => + postComboDevice(comboId, { deviceId }), + onSuccess: (_data, variables) => { + // ์กฐํ•ฉ ๋ชฉ๋ก ์บ์‹œ ๋ฌดํšจํ™” + queryClient.invalidateQueries({ queryKey: [queryKey.COMBOS] }); + // ์กฐํ•ฉ ์ƒ์„ธ ์ •๋ณด ์บ์‹œ ๋ฌดํšจํ™” + queryClient.invalidateQueries({ queryKey: [queryKey.COMBO_DETAIL] }); + // ์กฐํ•ฉ ํ‰๊ฐ€ ํด๋ง ์‹œ์ž‘ + poll(variables.comboId); + }, + }); +}; diff --git a/src/apis/combo/postComboPin.ts b/src/apis/combo/postComboPin.ts new file mode 100644 index 00000000..c45f6bea --- /dev/null +++ b/src/apis/combo/postComboPin.ts @@ -0,0 +1,23 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { PostComboPinResponse } from '@/types/combo/combo'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +// ์กฐํ•ฉ Pin ํ† ๊ธ€ +export const postComboPin = async (comboId: number): Promise => { + const { data } = await axiosInstance.post( + `/api/combos/${comboId}/pin` + ); + return data; +}; + +export const usePostComboPin = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (comboId: number) => postComboPin(comboId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [queryKey.COMBOS] }); + }, + }); +}; diff --git a/src/apis/combo/postCreateCombination.ts b/src/apis/combo/postCreateCombination.ts new file mode 100644 index 00000000..680dc0a1 --- /dev/null +++ b/src/apis/combo/postCreateCombination.ts @@ -0,0 +1,22 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { PostCreateCombinationRequest, PostCreateCombinationResponse } from '@/types/combo/createCombo' +import { useMutation } from '@tanstack/react-query'; +import type { AxiosError } from 'axios'; +import type { CommonResponse } from '@/types/common'; + +// ์กฐํ•ฉ ์ƒ์„ฑ API +export const postCreateCombination = async (payload: PostCreateCombinationRequest): Promise => { + const { data } = await axiosInstance.post('/api/combos', payload); + return data; +}; + +// ์กฐํ•ฉ ์ƒ์„ฑ Mutation +export const usePostCreateCombination = () => { + return useMutation< + PostCreateCombinationResponse, + AxiosError>, + PostCreateCombinationRequest + >({ + mutationFn: postCreateCombination, + }); +}; diff --git a/src/apis/combo/putCombos.ts b/src/apis/combo/putCombos.ts new file mode 100644 index 00000000..1af25ace --- /dev/null +++ b/src/apis/combo/putCombos.ts @@ -0,0 +1,29 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { PutComboRequest, PutComboResponse } from '@/types/combo/combo'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +// ์กฐํ•ฉ ์ˆ˜์ • +export const putCombo = async ( + comboId: number, + payload: PutComboRequest +): Promise => { + const { data } = await axiosInstance.put( + `/api/combos/${comboId}`, + payload + ); + return data; +}; + +export const usePutCombo = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ comboId, comboName }: { comboId: number; comboName: string }) => + putCombo(comboId, { comboName }), + onSuccess: () => { + // ์กฐํ•ฉ ๋ชฉ๋ก ์บ์‹œ ๋ฌดํšจํ™” (๋ฆฌํ”„๋ ˆ์‹œ) + queryClient.invalidateQueries({ queryKey: [queryKey.COMBOS] }); + }, + }); +}; diff --git a/src/apis/devices/getBrands.ts b/src/apis/devices/getBrands.ts new file mode 100644 index 00000000..828703b6 --- /dev/null +++ b/src/apis/devices/getBrands.ts @@ -0,0 +1,18 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { GetBrandsResponse } from '@/types/devices'; +import { useQuery } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +export const getBrands = async (deviceType?: string): Promise => { + const params = deviceType ? { deviceType } : {}; + const { data } = await axiosInstance.get('/api/brands', { params }); + return data; +}; + +export const useGetBrands = (deviceType?: string) => { + return useQuery({ + queryKey: [queryKey.BRANDS, deviceType], + queryFn: () => getBrands(deviceType), + enabled: true, + }); +}; diff --git a/src/apis/devices/searchDevices.ts b/src/apis/devices/searchDevices.ts new file mode 100644 index 00000000..b1e315c2 --- /dev/null +++ b/src/apis/devices/searchDevices.ts @@ -0,0 +1,35 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { + SearchDevicesParams, + GetDevicesSearchResponse, + DeviceSearchResult, +} from '@/types/devices'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +export const searchDevices = async ( + params: SearchDevicesParams +): Promise => { + const { data } = await axiosInstance.get( + '/api/devices/search', + { params } + ); + return data.result ?? { devices: [], nextCursor: null, hasNext: false }; +}; + +export const useSearchDevices = (params: Omit) => { + return useInfiniteQuery({ + queryKey: [queryKey.DEVICE_SEARCH, params], + queryFn: ({ pageParam }) => + searchDevices({ + ...params, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage?.hasNext ? lastPage.nextCursor : undefined, + enabled: true, + staleTime: 1000 * 60 * 5, + placeholderData: (prev) => prev, + }); +}; diff --git a/src/apis/findCredential/postFindId.ts b/src/apis/findCredential/postFindId.ts new file mode 100644 index 00000000..8abc7df7 --- /dev/null +++ b/src/apis/findCredential/postFindId.ts @@ -0,0 +1,17 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { FindIdRequest, FindIdResponse } from '@/types/findCredential/findId'; +import { useMutation } from '@tanstack/react-query'; + +export const postFindId = async (payload: FindIdRequest): Promise => { + const { data } = await axiosInstance.post( + '/api/find-credential/find-id', + payload + ); + return data; +}; + +export const usePostFindId = () => { + return useMutation({ + mutationFn: postFindId, + }); +}; diff --git a/src/apis/findCredential/postFindPassword.ts b/src/apis/findCredential/postFindPassword.ts new file mode 100644 index 00000000..12e6a135 --- /dev/null +++ b/src/apis/findCredential/postFindPassword.ts @@ -0,0 +1,64 @@ +import { cookieAxiosInstance } from '@/apis/axios/cookieAxios'; +import type { + SendMailRequest, + SendMailResponse, + VerifyCodeRequest, + VerifyCodeResponse, + ResetPasswordRequest, + ResetPasswordResponse, +} from '@/types/findCredential/findPassword'; +import { useMutation } from '@tanstack/react-query'; + +// step1 - ๋ฉ”์ผ ์ „์†ก ์š”์ฒญ API ํ•จ์ˆ˜ +export const postSendMail = async ( + payload: SendMailRequest +): Promise => { + const { data } = await cookieAxiosInstance.post( + '/api/find-credential/find-password/send-mail', + payload + ); + return data; +}; + +// step1 - ๋ฉ”์ผ ์ „์†ก ์š”์ฒญ ํ›… +export const usePostSendMail = () => { + return useMutation({ + mutationFn: postSendMail, + }); +}; + +// step2 - ์ธ์ฆ์ฝ”๋“œ ํ™•์ธ API ํ•จ์ˆ˜ +export const postVerifyCode = async ( + payload: VerifyCodeRequest +): Promise => { + const { data } = await cookieAxiosInstance.post( + '/api/find-credential/find-password/verify-code', + payload + ); + return data; +}; + +// step2 - ์ธ์ฆ์ฝ”๋“œ ํ™•์ธ ํ›… +export const usePostVerifyCode = () => { + return useMutation({ + mutationFn: postVerifyCode, + }); +}; + +// step3 - ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ฆฌ์…‹ API ํ•จ์ˆ˜ +export const postResetPassword = async ( + payload: ResetPasswordRequest +): Promise => { + const { data } = await cookieAxiosInstance.post( + '/api/find-credential/find-password/reset', + payload + ); + return data; +}; + +// step3 - ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ฆฌ์…‹ ํ›… +export const usePostResetPassword = () => { + return useMutation({ + mutationFn: postResetPassword, + }); +}; diff --git a/src/apis/lifestyle/getLifestyleDevice.ts b/src/apis/lifestyle/getLifestyleDevice.ts new file mode 100644 index 00000000..0764c2e4 --- /dev/null +++ b/src/apis/lifestyle/getLifestyleDevice.ts @@ -0,0 +1,25 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import { useQuery } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +import type { LifestyleDeviceResponse, LifestyleTagKey } from '@/types/lifestyle/lifestyle'; + +export const getLifestyleDevice = async ( + tagKey: LifestyleTagKey +): Promise => { + const { data } = await axiosInstance.get('/api/lifestyle/featured', { + params: { tagKey }, + }); + + return data; +}; + +export const useGetLifestyleDevice = (tagKey: LifestyleTagKey) => { + return useQuery({ + queryKey: [queryKey.LIFESTYLE_DEVICE, tagKey], + queryFn: () => getLifestyleDevice(tagKey), + enabled: !!tagKey, + staleTime: 1000 * 60 * 5, + placeholderData: (prev) => prev, + }); +}; diff --git a/src/apis/mypage/getUserProfile.ts b/src/apis/mypage/getUserProfile.ts new file mode 100644 index 00000000..dd3815dd --- /dev/null +++ b/src/apis/mypage/getUserProfile.ts @@ -0,0 +1,23 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { UserProfileResponse, UserProfileResult } from '@/types/mypage/user'; +import { useQuery } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; +import { hasAccessToken } from '@/utils/authStorage'; + +// ์œ ์ € ์ •๋ณด ์กฐํšŒ API +export const getUserProfile = async (): Promise => { + const { data } = await axiosInstance.get('/api/mypage/user-profile'); + return data.result; +}; + +// ์œ ์ € ์ •๋ณด ์กฐํšŒ Query +export const useGetUserProfile = () => { + const hasTokens = hasAccessToken(); + + return useQuery({ + queryKey: [queryKey.USER_PROFILE], + queryFn: getUserProfile, + enabled: hasTokens, // ํ† ํฐ์ด ์žˆ์„ ๋•Œ๋งŒ ์กฐํšŒ + staleTime: 1000 * 60 * 10, + }); +}; \ No newline at end of file diff --git a/src/apis/mypage/patchEditProfile.ts b/src/apis/mypage/patchEditProfile.ts new file mode 100644 index 00000000..1b6ff66b --- /dev/null +++ b/src/apis/mypage/patchEditProfile.ts @@ -0,0 +1,19 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import { useMutation } from '@tanstack/react-query'; +import type { EditProfileRequest, EditProfileResponse } from '@/types/mypage/editProfile'; + +export const patchEditProfile = async ( + payload: EditProfileRequest +): Promise => { + const { data } = await axiosInstance.patch( + '/api/mypage/user-profile', + payload + ); + return data; +}; + +export const usePatchEditProfile = () => { + return useMutation({ + mutationFn: patchEditProfile, + }); +}; diff --git a/src/apis/mypage/putEditPassword.ts b/src/apis/mypage/putEditPassword.ts new file mode 100644 index 00000000..f62cba68 --- /dev/null +++ b/src/apis/mypage/putEditPassword.ts @@ -0,0 +1,19 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import { useMutation } from '@tanstack/react-query'; +import type { EditPasswordRequest, EditPasswordResponse } from '@/types/mypage/editPassword'; + +export const putEditPassword = async ( + payload: EditPasswordRequest +): Promise => { + const { data } = await axiosInstance.put( + '/api/mypage/user-profile/password', + payload + ); + return data; +}; + +export const usePutEditPassword = () => { + return useMutation({ + mutationFn: putEditPassword, + }); +}; diff --git a/src/apis/onboarding/postComplete.ts b/src/apis/onboarding/postComplete.ts new file mode 100644 index 00000000..03aacb96 --- /dev/null +++ b/src/apis/onboarding/postComplete.ts @@ -0,0 +1,22 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { PostOnboardingCompleteResponse } from '@/types/onboarding/complete'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +// ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ API +export const postOnboardingComplete = async (): Promise => { + const { data } = await axiosInstance.post('/api/onboarding/complete', {}); + return data; +}; + +// ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ Mutation +export const usePostOnboardingComplete = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: postOnboardingComplete, + onSuccess: async () => { + // ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ ์‹œ ์œ ์ € ํ”„๋กœํ•„ refetch ์™„๋ฃŒ๊นŒ์ง€ ๋Œ€๊ธฐ (๊ฐ€๋“œ์—์„œ ์ตœ์‹  ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ) + await queryClient.refetchQueries({ queryKey: [queryKey.USER_PROFILE] }); + }, + }); +}; diff --git a/src/apis/recentlyViewed/getRecentlyViewed.ts b/src/apis/recentlyViewed/getRecentlyViewed.ts new file mode 100644 index 00000000..89037d20 --- /dev/null +++ b/src/apis/recentlyViewed/getRecentlyViewed.ts @@ -0,0 +1,19 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import { useQuery } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; +import type { RecentlyViewedDevicesResponse, RecentlyViewedDevice } from '@/types/recentlyViewed/recentlyViewed'; + +// ์ตœ๊ทผ ๋ณธ ๊ธฐ๊ธฐ ๋ชฉ๋ก ์กฐํšŒ API +export const getRecentlyViewed = async (): Promise => { + const { data } = await axiosInstance.get('/api/recently-viewed'); + return data.result ?? []; +}; + +// ์ตœ๊ทผ ๋ณธ ๊ธฐ๊ธฐ ๋ชฉ๋ก ์กฐํšŒ Query +export const useGetRecentlyViewed = () => { + return useQuery({ + queryKey: [queryKey.RECENTLY_VIEWED], + queryFn: getRecentlyViewed, + staleTime: 1000 * 30, // 30์ดˆ๊ฐ„ ์บ์‹œ ์œ ์ง€ + }); +}; diff --git a/src/apis/recentlyViewed/postRecentlyViewed.ts b/src/apis/recentlyViewed/postRecentlyViewed.ts new file mode 100644 index 00000000..b1102300 --- /dev/null +++ b/src/apis/recentlyViewed/postRecentlyViewed.ts @@ -0,0 +1,24 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; +import type { CommonResponse } from '@/types/common'; + +// ์ตœ๊ทผ ๋ณธ ๊ธฐ๊ธฐ ๊ธฐ๋ก API +export const postRecentlyViewed = async (deviceId: number): Promise => { + const { data } = await axiosInstance.post( + `/api/recently-viewed/${deviceId}` + ); + return data; +}; + +// ์ตœ๊ทผ ๋ณธ ๊ธฐ๊ธฐ ๊ธฐ๋ก Mutation +export const usePostRecentlyViewed = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (deviceId: number) => postRecentlyViewed(deviceId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [queryKey.RECENTLY_VIEWED] }); + }, + }); +}; diff --git a/src/apis/tag/getTags.ts b/src/apis/tag/getTags.ts new file mode 100644 index 00000000..26b5c361 --- /dev/null +++ b/src/apis/tag/getTags.ts @@ -0,0 +1,23 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { GetTagsResponse, GetTagsResult } from '@/types/tag/tag'; +import { useQuery } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +// ํƒœ๊ทธ ๋ชฉ๋ก ์กฐํšŒ API +// ์ „์ฒด ํƒœ๊ทธ๋ฅผ ๊ฐ€์ ธ์˜ค๋ ค๋ฉด ํ•ญ์ƒ type: 'LIFESTYLE'์„ ๋ณด๋‚ด์•ผ ํ•จ +export const getTags = async (): Promise => { + const { data } = await axiosInstance.get('/api/tags', { + params: { type: 'LIFESTYLE' }, + }); + return data.result ?? []; +}; + +// ํƒœ๊ทธ ๋ชฉ๋ก ์กฐํšŒ Query +export const useGetTags = () => { + return useQuery({ + queryKey: [queryKey.TAGS], + queryFn: getTags, + staleTime: 1000 * 60 * 60, // 1์‹œ๊ฐ„ + gcTime: 1000 * 60 * 60 * 24, // 24์‹œ๊ฐ„ + }); +}; diff --git a/src/apis/tag/postTags.ts b/src/apis/tag/postTags.ts new file mode 100644 index 00000000..42fb0a00 --- /dev/null +++ b/src/apis/tag/postTags.ts @@ -0,0 +1,22 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { PostUserTagsRequest, PostUserTagsResponse } from '@/types/tag/tag'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +// ์œ ์ € ํƒœ๊ทธ ์ €์žฅ API (replace ๋™์ž‘) +export const postUserTags = async (payload: PostUserTagsRequest): Promise => { + const { data } = await axiosInstance.post('/api/tags/user', payload); + return data; +}; + +// ์œ ์ € ํƒœ๊ทธ ์ €์žฅ Mutation +export const usePostUserTags = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: postUserTags, + onSuccess: async () => { + // ํƒœ๊ทธ ์ €์žฅ ์„ฑ๊ณต ์‹œ ์œ ์ € ํ”„๋กœํ•„ refetch ์™„๋ฃŒ๊นŒ์ง€ ๋Œ€๊ธฐ (๋‹ค์Œ ํŽ˜์ด์ง€์—์„œ ์ตœ์‹  ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ) + await queryClient.refetchQueries({ queryKey: [queryKey.USER_PROFILE] }); + }, + }); +}; diff --git a/src/assets/fonts/KIMM_bold.woff2 b/src/assets/fonts/KIMM_bold.woff2 new file mode 100644 index 00000000..b069578d Binary files /dev/null and b/src/assets/fonts/KIMM_bold.woff2 differ diff --git a/src/assets/fonts/WantedSansVariable.woff2 b/src/assets/fonts/WantedSansVariable.woff2 new file mode 100644 index 00000000..edca6ec9 Binary files /dev/null and b/src/assets/fonts/WantedSansVariable.woff2 differ diff --git a/src/assets/icons/X.svg b/src/assets/icons/X.svg new file mode 100644 index 00000000..0316ab5c --- /dev/null +++ b/src/assets/icons/X.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/alarm.svg b/src/assets/icons/alarm.svg new file mode 100644 index 00000000..b683b836 --- /dev/null +++ b/src/assets/icons/alarm.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/back.svg b/src/assets/icons/back.svg new file mode 100644 index 00000000..4dc809df --- /dev/null +++ b/src/assets/icons/back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/back_gray.svg b/src/assets/icons/back_gray.svg new file mode 100644 index 00000000..1aded9d2 --- /dev/null +++ b/src/assets/icons/back_gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/cancel.svg b/src/assets/icons/cancel.svg new file mode 100644 index 00000000..f3b25411 --- /dev/null +++ b/src/assets/icons/cancel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/charge.svg b/src/assets/icons/charge.svg new file mode 100644 index 00000000..b7386fec --- /dev/null +++ b/src/assets/icons/charge.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/checkbox.svg b/src/assets/icons/checkbox.svg new file mode 100644 index 00000000..c52dd5f6 --- /dev/null +++ b/src/assets/icons/checkbox.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/checkbox_on.svg b/src/assets/icons/checkbox_on.svg new file mode 100644 index 00000000..6448b7ed --- /dev/null +++ b/src/assets/icons/checkbox_on.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/closedeye.svg b/src/assets/icons/closedeye.svg new file mode 100644 index 00000000..be520238 --- /dev/null +++ b/src/assets/icons/closedeye.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/connectivity.svg b/src/assets/icons/connectivity.svg new file mode 100644 index 00000000..86022d9b --- /dev/null +++ b/src/assets/icons/connectivity.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/dropdown.svg b/src/assets/icons/dropdown.svg new file mode 100644 index 00000000..1d62b0cc --- /dev/null +++ b/src/assets/icons/dropdown.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/ellipse_black.svg b/src/assets/icons/ellipse_black.svg new file mode 100644 index 00000000..dafe9283 --- /dev/null +++ b/src/assets/icons/ellipse_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/ellipse_gray.svg b/src/assets/icons/ellipse_gray.svg new file mode 100644 index 00000000..ee6ca192 --- /dev/null +++ b/src/assets/icons/ellipse_gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/eye.svg b/src/assets/icons/eye.svg new file mode 100644 index 00000000..6bb63642 --- /dev/null +++ b/src/assets/icons/eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/filter.svg b/src/assets/icons/filter.svg new file mode 100644 index 00000000..d6e0e9ab --- /dev/null +++ b/src/assets/icons/filter.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/headset.svg b/src/assets/icons/headset.svg new file mode 100644 index 00000000..e34e4b7b --- /dev/null +++ b/src/assets/icons/headset.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/keyboard.svg b/src/assets/icons/keyboard.svg new file mode 100644 index 00000000..d32c507d --- /dev/null +++ b/src/assets/icons/keyboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/laptop.svg b/src/assets/icons/laptop.svg new file mode 100644 index 00000000..a458759d --- /dev/null +++ b/src/assets/icons/laptop.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/lifestyle.svg b/src/assets/icons/lifestyle.svg new file mode 100644 index 00000000..cc566031 --- /dev/null +++ b/src/assets/icons/lifestyle.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/logicevaluation.svg b/src/assets/icons/logicevaluation.svg new file mode 100644 index 00000000..227db9f0 --- /dev/null +++ b/src/assets/icons/logicevaluation.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/icons/more.svg b/src/assets/icons/more.svg new file mode 100644 index 00000000..782dd196 --- /dev/null +++ b/src/assets/icons/more.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/mouse.svg b/src/assets/icons/mouse.svg new file mode 100644 index 00000000..3cfda922 --- /dev/null +++ b/src/assets/icons/mouse.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/onboarding_lines.svg b/src/assets/icons/onboarding_lines.svg new file mode 100644 index 00000000..09ecfb2c --- /dev/null +++ b/src/assets/icons/onboarding_lines.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/phone.svg b/src/assets/icons/phone.svg new file mode 100644 index 00000000..9e109cd7 --- /dev/null +++ b/src/assets/icons/phone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/plus.svg b/src/assets/icons/plus.svg new file mode 100644 index 00000000..8d880e78 --- /dev/null +++ b/src/assets/icons/plus.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/portability.svg b/src/assets/icons/portability.svg new file mode 100644 index 00000000..dedde9d2 --- /dev/null +++ b/src/assets/icons/portability.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/remove.svg b/src/assets/icons/remove.svg new file mode 100644 index 00000000..43618974 --- /dev/null +++ b/src/assets/icons/remove.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/save.svg b/src/assets/icons/save.svg new file mode 100644 index 00000000..3e295e50 --- /dev/null +++ b/src/assets/icons/save.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/search.svg b/src/assets/icons/search.svg new file mode 100644 index 00000000..30d2859d --- /dev/null +++ b/src/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/setting.svg b/src/assets/icons/setting.svg new file mode 100644 index 00000000..e16f3985 --- /dev/null +++ b/src/assets/icons/setting.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/settingmore.svg b/src/assets/icons/settingmore.svg new file mode 100644 index 00000000..caac8cb4 --- /dev/null +++ b/src/assets/icons/settingmore.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/star.svg b/src/assets/icons/star.svg new file mode 100644 index 00000000..33ccc802 --- /dev/null +++ b/src/assets/icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/starhover.svg b/src/assets/icons/starhover.svg new file mode 100644 index 00000000..523883ff --- /dev/null +++ b/src/assets/icons/starhover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/starx.svg b/src/assets/icons/starx.svg new file mode 100644 index 00000000..5e0a0596 --- /dev/null +++ b/src/assets/icons/starx.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/support.svg b/src/assets/icons/support.svg new file mode 100644 index 00000000..617bf8a5 --- /dev/null +++ b/src/assets/icons/support.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/tablet.svg b/src/assets/icons/tablet.svg new file mode 100644 index 00000000..e299d2e1 --- /dev/null +++ b/src/assets/icons/tablet.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/top.svg b/src/assets/icons/top.svg new file mode 100644 index 00000000..fdd32c2e --- /dev/null +++ b/src/assets/icons/top.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/trash.svg b/src/assets/icons/trash.svg new file mode 100644 index 00000000..7fee4a0b --- /dev/null +++ b/src/assets/icons/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/userblack.svg b/src/assets/icons/userblack.svg new file mode 100644 index 00000000..833f50bb --- /dev/null +++ b/src/assets/icons/userblack.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/userblue500.svg b/src/assets/icons/userblue500.svg new file mode 100644 index 00000000..66ed8a66 --- /dev/null +++ b/src/assets/icons/userblue500.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/userblue600.svg b/src/assets/icons/userblue600.svg new file mode 100644 index 00000000..9ddc6dd8 --- /dev/null +++ b/src/assets/icons/userblue600.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/warning.svg b/src/assets/icons/warning.svg new file mode 100644 index 00000000..e15b790e --- /dev/null +++ b/src/assets/icons/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/watch.svg b/src/assets/icons/watch.svg new file mode 100644 index 00000000..03218491 --- /dev/null +++ b/src/assets/icons/watch.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/combination/stage1.svg b/src/assets/images/combination/stage1.svg new file mode 100644 index 00000000..b9cfdd63 --- /dev/null +++ b/src/assets/images/combination/stage1.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/combination/stage2.svg b/src/assets/images/combination/stage2.svg new file mode 100644 index 00000000..22855159 --- /dev/null +++ b/src/assets/images/combination/stage2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/combination/stage3.svg b/src/assets/images/combination/stage3.svg new file mode 100644 index 00000000..63e9c345 --- /dev/null +++ b/src/assets/images/combination/stage3.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/error/Error404.svg b/src/assets/images/error/Error404.svg new file mode 100644 index 00000000..29ef0b07 --- /dev/null +++ b/src/assets/images/error/Error404.svg @@ -0,0 +1,564 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/home/HomeImage1.svg b/src/assets/images/home/HomeImage1.svg new file mode 100644 index 00000000..955e527d --- /dev/null +++ b/src/assets/images/home/HomeImage1.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/home/HomeImage2.svg b/src/assets/images/home/HomeImage2.svg new file mode 100644 index 00000000..cf052a90 --- /dev/null +++ b/src/assets/images/home/HomeImage2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/home/HomeImage3.svg b/src/assets/images/home/HomeImage3.svg new file mode 100644 index 00000000..780fedd9 --- /dev/null +++ b/src/assets/images/home/HomeImage3.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/lifestyle/developer.jpg b/src/assets/images/lifestyle/developer.jpg new file mode 100644 index 00000000..5a3bb47b Binary files /dev/null and b/src/assets/images/lifestyle/developer.jpg differ diff --git a/src/assets/images/lifestyle/game.jpg b/src/assets/images/lifestyle/game.jpg new file mode 100644 index 00000000..fc99b678 Binary files /dev/null and b/src/assets/images/lifestyle/game.jpg differ diff --git a/src/assets/images/lifestyle/office.jpg b/src/assets/images/lifestyle/office.jpg new file mode 100644 index 00000000..8845dd54 Binary files /dev/null and b/src/assets/images/lifestyle/office.jpg differ diff --git a/src/assets/images/lifestyle/study.jpg b/src/assets/images/lifestyle/study.jpg new file mode 100644 index 00000000..deb76d7b Binary files /dev/null and b/src/assets/images/lifestyle/study.jpg differ diff --git a/src/assets/images/lifestyle/tour.jpg b/src/assets/images/lifestyle/tour.jpg new file mode 100644 index 00000000..4980b8f6 Binary files /dev/null and b/src/assets/images/lifestyle/tour.jpg differ diff --git a/src/assets/images/lifestyle/video-editing.jpg b/src/assets/images/lifestyle/video-editing.jpg new file mode 100644 index 00000000..500d12db Binary files /dev/null and b/src/assets/images/lifestyle/video-editing.jpg differ diff --git a/src/assets/logos/apple.svg b/src/assets/logos/apple.svg new file mode 100644 index 00000000..ff7d23aa --- /dev/null +++ b/src/assets/logos/apple.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/logos/google.svg b/src/assets/logos/google.svg new file mode 100644 index 00000000..a7d2d56a --- /dev/null +++ b/src/assets/logos/google.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/logos/google_noborder.svg b/src/assets/logos/google_noborder.svg new file mode 100644 index 00000000..3ceec7a9 --- /dev/null +++ b/src/assets/logos/google_noborder.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/logos/logo.svg b/src/assets/logos/logo.svg new file mode 100644 index 00000000..62919200 --- /dev/null +++ b/src/assets/logos/logo.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/logos/logo_circle.svg b/src/assets/logos/logo_circle.svg new file mode 100644 index 00000000..6a86de11 --- /dev/null +++ b/src/assets/logos/logo_circle.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9b..00000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/Auth/FindPasswordStep/Step1Form.tsx b/src/components/Auth/FindPasswordStep/Step1Form.tsx new file mode 100644 index 00000000..7051f2b3 --- /dev/null +++ b/src/components/Auth/FindPasswordStep/Step1Form.tsx @@ -0,0 +1,77 @@ +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { findPasswordSchema, type FindPasswordFormData } from '@/schemas/authSchema'; +import PrimaryInput from '@/components/Input/PrimaryInput'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import { parseApiError } from '@/utils/error'; + +type Step1FormProps = { + onSubmit: (data: FindPasswordFormData) => Promise; + onInvalid: () => void; + isPending: boolean; + hasSubmitted: boolean; +}; + +const Step1Form = ({ onSubmit, onInvalid, isPending }: Step1FormProps) => { + const { + register, + handleSubmit, + formState: { errors }, + setError, + } = useForm({ + resolver: zodResolver(findPasswordSchema), + mode: 'onSubmit', + reValidateMode: 'onChange', + }); + + const handleFormSubmit = async (data: FindPasswordFormData) => { + try { + await onSubmit(data); + } catch (error: unknown) { + const { hasResponse, message } = parseApiError(error); + if (hasResponse) { + setError('email', { + type: 'manual', + message: message ?? '์ธ์ฆ๋ฒˆํ˜ธ ๋ฐœ์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.', + }); + } else { + // ๋„คํŠธ์›Œํฌ/ํ™˜๊ฒฝ ์—๋Ÿฌ๋Š” ์ƒ์œ„๋กœ ์ „๋‹ฌํ•˜์ง€ ์•Š๊ณ  alert๋กœ ์ฒ˜๋ฆฌ + alert('์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.'); + } + } + }; + + return ( +
+ {/* ๋ฉ”์ธ ํผ ์˜์—ญ */} +
+ {/* ๋กœ๊ณ  */} +

Device Life

+ + {/* ํผ ์ปจํ…Œ์ด๋„ˆ */} +
+ {/* ์ž…๋ ฅ ์˜์—ญ */} +
+ + {errors.email && ( +

{errors.email.message}

+ )} +
+ + {/* ์ธ์ฆ๋ฒˆํ˜ธ ๋ฐ›๊ธฐ ๋ฒ„ํŠผ */} + + +
+
+ ); +}; + +export default Step1Form; diff --git a/src/components/Auth/FindPasswordStep/Step2Verification.tsx b/src/components/Auth/FindPasswordStep/Step2Verification.tsx new file mode 100644 index 00000000..3e844dc8 --- /dev/null +++ b/src/components/Auth/FindPasswordStep/Step2Verification.tsx @@ -0,0 +1,84 @@ +import PrimaryInput from '@/components/Input/PrimaryInput'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import SecondaryButton from '@/components/Button/SecondaryButton'; +import { formatTime } from '@/utils/format'; + +type Step2VerificationProps = { + verificationCode: string; + onVerificationCodeChange: (value: string) => void; + onVerify: () => void; + onResend: () => void; + timeLeft: number; + verifyError: string; + isVerifyPending: boolean; + isResendLimitReached?: boolean; +}; + +const Step2Verification = ({ + verificationCode, + onVerificationCodeChange, + onVerify, + onResend, + timeLeft, + verifyError, + isVerifyPending, + isResendLimitReached = false, +}: Step2VerificationProps) => { + return ( +
+ {/* ํƒ€์ดํ‹€ ์˜์—ญ */} +
+ {/* ๋ฉ”์ธ ํƒ€์ดํ‹€ */} +

+ ์ด๋ฉ”์ผ๋กœ ๋ฐœ์†ก๋œ ์ธ์ฆ๋ฒˆํ˜ธ 6์ž๋ฆฌ๋ฅผ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š” +

+ {/* ๋‚จ์€ ์‹œ๊ฐ„ */} +
+ ๋‚จ์€ ์‹œ๊ฐ„ + {formatTime(timeLeft)} +
+
+ + {/* ์ž…๋ ฅ + ๋ฒ„ํŠผ ์˜์—ญ */} +
+ {/* ์ธ์ฆ๋ฒˆํ˜ธ ์ž…๋ ฅ + ์žฌ์ „์†ก ๋ฒ„ํŠผ */} +
+
+ { + onVerificationCodeChange(e.target.value); + }} + maxLength={6} + /> + +
+ {verifyError && ( +

{verifyError}

+ )} +
+ + {/* ํ™•์ธ ๋ฒ„ํŠผ */} + 0 && !isVerifyPending + ? 'bg-blue-600 hover:bg-blue-500' + : '' + }`} + /> +
+
+ ); +}; + +export default Step2Verification; diff --git a/src/components/Auth/FindPasswordStep/Step3Reset.tsx b/src/components/Auth/FindPasswordStep/Step3Reset.tsx new file mode 100644 index 00000000..7298edce --- /dev/null +++ b/src/components/Auth/FindPasswordStep/Step3Reset.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { resetPasswordSchema, type ResetPasswordFormData } from '@/schemas/authSchema'; +import PrimaryInput from '@/components/Input/PrimaryInput'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import InputEyeIcon from '@/assets/icons/eye.svg?react'; + +type Step3ResetProps = { + onSubmit: (data: ResetPasswordFormData) => Promise; + resetError: string; + isResetPending: boolean; + onResetErrorClear?: () => void; +}; + +const Step3Reset = ({ onSubmit, resetError, isResetPending, onResetErrorClear }: Step3ResetProps) => { + const [isNewPasswordFocused, setIsNewPasswordFocused] = useState(false); + const [isNewPasswordVisible, setIsNewPasswordVisible] = useState(false); + + const { + register: registerReset, + handleSubmit: handleResetSubmit, + formState: { errors: resetErrors, isValid: isResetValid }, + } = useForm({ + resolver: zodResolver(resetPasswordSchema), + mode: 'onChange', + reValidateMode: 'onChange', + }); + + return ( +
+ {/* ํƒ€์ดํ‹€ */} +

+ ์ƒˆ๋กœ์šด ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์„ค์ •ํ•ด ์ฃผ์„ธ์š” +

+ + {/* ์ž…๋ ฅ ํ•„๋“œ + ๋ฒ„ํŠผ ์˜์—ญ */} +
+ {/* ์ž…๋ ฅ ํ•„๋“œ๋“ค */} +
+ {/* ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ */} +
+

์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ

+
+ { + const { onChange, ...rest } = registerReset('newPassword'); + return { + ...rest, + onChange: (e) => { + onChange(e); + onResetErrorClear?.(); + }, + }; + })()} + type={isNewPasswordVisible ? 'text' : 'password'} + placeholder="์˜๋ฌธ+์ˆซ์ž ์กฐํ•ฉ *~20์ž" + maxLength={20} + onFocus={() => setIsNewPasswordFocused(true)} + onBlur={() => setIsNewPasswordFocused(false)} + /> + {isNewPasswordFocused && ( + { + e.preventDefault(); + setIsNewPasswordVisible((prev) => !prev); + }} + /> + )} +
+ {resetErrors.newPassword && ( +

+ {resetErrors.newPassword.message} +

+ )} +
+ + {/* ์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ */} +
+

์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ

+ { + const { onChange, ...rest } = registerReset('newPasswordConfirm'); + return { + ...rest, + onChange: (e) => { + onChange(e); + onResetErrorClear?.(); + }, + }; + })()} + type="password" + placeholder="๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ํ•œ ๋ฒˆ ๋” ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”" + maxLength={20} + /> + {resetErrors.newPasswordConfirm && ( +

+ {resetErrors.newPasswordConfirm.message} +

+ )} + {resetError && ( +

{resetError}

+ )} +
+
+ + {/* ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝํ•˜๊ธฐ ๋ฒ„ํŠผ */} +
+ +
+
+
+ ); +}; + +export default Step3Reset; diff --git a/src/components/Auth/Indicator/StepIndicator.tsx b/src/components/Auth/Indicator/StepIndicator.tsx new file mode 100644 index 00000000..96f9a8dc --- /dev/null +++ b/src/components/Auth/Indicator/StepIndicator.tsx @@ -0,0 +1,54 @@ +import EllipseBlack from '@/assets/icons/ellipse_black.svg?react'; +import EllipseGray from '@/assets/icons/ellipse_gray.svg?react'; +import { useOnboardingNavigation } from '@/hooks/useOnboardingNavigation'; +import { useAuth } from '@/hooks/useAuth'; +import clsx from 'clsx'; + +type StepIndicatorProps = { + currentStep: number; + totalSteps?: number; + className?: string; +}; + +const StepIndicator = ({ currentStep, totalSteps = 4, className = '' }: StepIndicatorProps) => { + const { handleStepClick: navigateToStep } = useOnboardingNavigation(); + const { isLoggedIn } = useAuth(); + + const handleStepClick = (step: number) => { + // ์ด์ „ step๋งŒ ํด๋ฆญ ๊ฐ€๋Šฅ + if (step < currentStep) { + navigateToStep(step); + } + }; + + return ( +
+ {Array.from({ length: totalSteps }, (_, index) => { + const step = index + 1; + const isActive = step === currentStep; + const isPrevious = step < currentStep; + // ๋กœ๊ทธ์ธ๋œ ์ƒํƒœ์—์„œ๋Š” ํšŒ์›๊ฐ€์ž… ํŽ˜์ด์ง€(step 1, 2) ํด๋ฆญ ๋ถˆ๊ฐ€ + const isSignupStep = step === 1 || step === 2; + const isClickable = isPrevious && (!isLoggedIn || !isSignupStep); + const Icon = isActive ? EllipseBlack : EllipseGray; + return ( + + ); + })} +
+ ); +}; + +export default StepIndicator; diff --git a/src/components/Auth/Label/InputLabel.tsx b/src/components/Auth/Label/InputLabel.tsx new file mode 100644 index 00000000..07bedecb --- /dev/null +++ b/src/components/Auth/Label/InputLabel.tsx @@ -0,0 +1,27 @@ +import clsx from 'clsx'; + +type InputLabelProps = { + text: string; + required?: boolean; + className?: string; +}; + +const InputLabel = ({ text, required = true, className }: InputLabelProps) => { + return ( + + ); +}; + +export default InputLabel; diff --git a/src/components/Button/GoogleLoginButton.tsx b/src/components/Button/GoogleLoginButton.tsx new file mode 100644 index 00000000..fc6ada27 --- /dev/null +++ b/src/components/Button/GoogleLoginButton.tsx @@ -0,0 +1,38 @@ +import GoogleLogo from '@/assets/logos/google_noborder.svg?react'; +import clsx from 'clsx'; +import { OAUTH } from '@/constants/auth'; + +type GoogleLoginButtonProps = { + onClick?: () => void; + className?: string; +}; + +const GoogleLoginButton = ({ onClick, className }: GoogleLoginButtonProps) => { + const handleClick = () => { + if (onClick) { + onClick(); + } else { + // Google OAuth ์ธ์ฆ ํŽ˜์ด์ง€๋กœ ์ด๋™ + window.location.href = OAUTH.google; + } + }; + + return ( + + ); +}; + +export default GoogleLoginButton; diff --git a/src/components/Button/PrimaryButton.tsx b/src/components/Button/PrimaryButton.tsx new file mode 100644 index 00000000..3c18ecaa --- /dev/null +++ b/src/components/Button/PrimaryButton.tsx @@ -0,0 +1,27 @@ +import clsx from 'clsx'; + +type PrimaryButtonProps = React.ComponentProps<'button'> & { + text: string; +}; + +const PrimaryButton = ({ text, disabled = false, className, ...props }: PrimaryButtonProps) => { + return ( + + ); +}; + +export default PrimaryButton; diff --git a/src/components/Button/SecondaryButton.tsx b/src/components/Button/SecondaryButton.tsx new file mode 100644 index 00000000..e4add14c --- /dev/null +++ b/src/components/Button/SecondaryButton.tsx @@ -0,0 +1,34 @@ +import { cn } from '@/utils/cn'; + +type SecondaryButtonProps = { + text: string; + onClick?: () => void; + className?: string; + disabled?: boolean; +}; + +const SecondaryButton = ({ + text, + onClick, + className = '', + disabled = false, +}: SecondaryButtonProps) => { + return ( + + ); +}; + +export default SecondaryButton; diff --git a/src/components/Button/SignupButton.tsx b/src/components/Button/SignupButton.tsx new file mode 100644 index 00000000..f404ba82 --- /dev/null +++ b/src/components/Button/SignupButton.tsx @@ -0,0 +1,54 @@ +import { type ReactNode } from 'react'; +import clsx from 'clsx'; + +type SignupButtonProps = { + text: string; + icon: ReactNode; + onClick?: () => void; + disabled?: boolean; + className?: string; + + /** Figma spacing: 132 / 146 */ + textStart?: number; +}; + +const SignupButton = ({ + text, + icon, + onClick, + disabled = false, + className = '', + textStart = 146, +}: SignupButtonProps) => { + return ( + + ); +}; + +export default SignupButton; diff --git a/src/components/Combination/CombinationDeviceCard.tsx b/src/components/Combination/CombinationDeviceCard.tsx new file mode 100644 index 00000000..2437dca8 --- /dev/null +++ b/src/components/Combination/CombinationDeviceCard.tsx @@ -0,0 +1,158 @@ +import { useState } from 'react'; +import StarIcon from '@/assets/icons/star.svg?react'; +import type { ComboListItem, ComboDevice } from '@/types/combo/combo'; +import type { CombinationStatus } from '@/constants/combination'; +import CombinationTag from '@/components/Combination/CombinationTag'; +import { useComboEvaluation } from '@/apis/combo/getComboEvaluation'; + +type CombinationDeviceCardProps = { + combination: ComboListItem; + devices: ComboDevice[]; + columns?: 3 | 4; + defaultRows?: number; + showExpandButton?: boolean; + showGradient?: boolean; + className?: string; + expanded?: boolean; + onExpand?: (expanded: boolean) => void; + index?: number; +}; + +const CombinationDeviceCard = ({ + combination, + devices, + columns = 3, + defaultRows = 3, + showExpandButton = true, + showGradient = true, + className = '', + expanded, + onExpand, + index, +}: CombinationDeviceCardProps) => { + const [internalExpanded, setInternalExpanded] = useState(false); + + // ํ‰๊ฐ€ ๋ฐ์ดํ„ฐ ์กฐํšŒ (๋งˆ์ดํŽ˜์ด์ง€์™€ ๋™์ผํ•œ ๋ฐ์ดํ„ฐ ์†Œ์Šค) + const { data: evaluation } = useComboEvaluation(combination.comboId); + + const isControlled = expanded !== undefined; + const showAllDevices = isControlled ? expanded : internalExpanded; + + const defaultDeviceCount = columns * defaultRows; + const hasMoreDevices = devices.length > defaultDeviceCount; + const displayedDevices = showAllDevices ? devices : devices.slice(0, defaultDeviceCount); + + const gradientThreshold = columns === 4 ? 9 : 7; + const shouldShowGradient = devices.length >= gradientThreshold; + + const handleExpand = () => { + if (!isControlled) { + setInternalExpanded(true); + } + onExpand?.(true); + }; + + const handleCollapse = () => { + if (!isControlled) { + setInternalExpanded(false); + } + onExpand?.(false); + }; + + const gridColsClass = columns === 4 ? 'grid-cols-4' : 'grid-cols-3'; + const deviceCardWidth = 'w-244 h-87'; + const deviceImageSize = 'w-63 h-63'; + + return ( +
+ {/* ์กฐํ•ฉ ์ •๋ณด */} +
+ {/* ์กฐํ•ฉ๋ช… */} +
+ {/* ์กฐํ•ฉ ๋ฒˆํ˜ธ */} + {index !== undefined && ( +

์กฐํ•ฉ {index + 1}

+ )} + {/* ์กฐํ•ฉ๋ช… + ๋ณ„ */} +
+

{combination.comboName}

+ {combination.isPinned && } +
+
+ {/* ํ‰๊ฐ€ ํƒœ๊ทธ */} +
+ + + +
+
+ + {/* ๊ธฐ๊ธฐ ๊ทธ๋ฆฌ๋“œ - ํƒœ๊ทธ์™€์˜ ๋งˆ์ง„ 24px (mt-24) */} +
+
+ {displayedDevices.map((device) => ( +
+
+ {device.imageUrl && ( + {device.name} + )} +
+
+

{device.name}

+

{device.brandName}

+

{device.deviceType}

+
+
+ ))} +
+ + {/* ๊ทธ๋ผ๋ฐ์ด์…˜ */} + {showGradient && shouldShowGradient && !showAllDevices && ( +
+ )} +
+ + {/* ๊ธฐ๊ธฐ ์ „์ฒด๋ณด๊ธฐ / ๊ฐ„๋žตํžˆ ๋ณด๊ธฐ ๋ฒ„ํŠผ */} + {showExpandButton && hasMoreDevices && !showAllDevices && ( + + )} + {showExpandButton && hasMoreDevices && showAllDevices && ( + + )} +
+ ); +}; + +export default CombinationDeviceCard; + diff --git a/src/components/Combination/CombinationEvaluationCard.tsx b/src/components/Combination/CombinationEvaluationCard.tsx new file mode 100644 index 00000000..64f3ef4a --- /dev/null +++ b/src/components/Combination/CombinationEvaluationCard.tsx @@ -0,0 +1,52 @@ +import type { CombinationName, CombinationStatus } from '@/constants/combination'; +import { + COMBINATION_NAME_STYLE_MAP, + COMBINATION_STATUS_STYLE_MAP, +} from '@/constants/combination'; +import type { Grade } from '@/constants/evaluation/grade'; + +interface CombinationEvaluationCardProps { + category: CombinationName; + grade: Grade; + description: string; + tags: string[]; +} + +// ๋“ฑ๊ธ‰ ํ…์ŠคํŠธ ์ƒ‰์ƒ ํด๋ž˜์Šค ์ถ”์ถœ (COMBINATION_STATUS_STYLE_MAP์—์„œ ์ƒ‰์ƒ๋งŒ ์ถ”์ถœ) +const getGradeTextColorClass = (grade: Grade): string => { + const statusStyle = COMBINATION_STATUS_STYLE_MAP[grade as CombinationStatus]; + // 'font-caption-sm text-optimal' ํ˜•ํƒœ์—์„œ ์ƒ‰์ƒ ๋ถ€๋ถ„๋งŒ ์ถ”์ถœ + return statusStyle.split(' ').find((cls) => cls.startsWith('text-')) ?? 'text-optimal'; +}; + +const CombinationEvaluationCard = ({ + category, + grade, + description, + tags, +}: CombinationEvaluationCardProps) => { + const gradeTextColorClass = getGradeTextColorClass(grade); + const tagStyleClass = COMBINATION_NAME_STYLE_MAP[category]; + + return ( +
+
+

{category}:

+

{grade}

+
+

{description}

+
+ {tags.map((tag) => ( + + {tag} + + ))} +
+
+ ); +}; + +export default CombinationEvaluationCard; diff --git a/src/components/Combination/CombinationResultOverlay.tsx b/src/components/Combination/CombinationResultOverlay.tsx new file mode 100644 index 00000000..09e155b8 --- /dev/null +++ b/src/components/Combination/CombinationResultOverlay.tsx @@ -0,0 +1,116 @@ +import PrimaryButton from '@/components/Button/PrimaryButton'; +import { COMBO_MOTION as M } from '@/constants/combination'; +import { useNavigate } from 'react-router-dom'; + +type Props = { + centerText: string; + resultOn: boolean; + phase: 'idle' | 'shrink' | 'stack' | 'done'; + showDouble: boolean; + showExtras: boolean; + targetRef: React.RefObject; +}; + +const CombinationResultOverlay = ({ + centerText, + resultOn, + phase, + showDouble, + showExtras, + targetRef, +}: Props) => { + const innerSize = + phase === 'shrink' ? { w: M.SHRINK_W, h: M.SHRINK_H } : { w: M.INNER_W, h: M.INNER_H }; + + const liftActive = phase === 'done'; + + const liftStyle: React.CSSProperties = liftActive + ? { + transform: `translate3d(0, -${M.LIFT_DISTANCE}px, 0)`, + transitionProperty: 'transform', + transitionDuration: `${M.LIFT_DURATION}ms`, + transitionDelay: `${M.LIFT_DELAY}ms`, + transitionTimingFunction: M.LIFT_EASING, + willChange: 'transform', + } + : { + transform: 'translate3d(0, 0, 0)', + transitionProperty: 'transform', + transitionDuration: `260ms`, + transitionTimingFunction: 'ease-out', + willChange: undefined, + }; + + const navigate = useNavigate(); + + return ( +
+
+
+
+
+
+ {centerText} +
+
+
+
+

+ ์ด์ œ ๊ธฐ๊ธฐ๊ฒ€์ƒ‰ ์ฐฝ์—์„œ ์›ํ•˜๋Š” ๊ธฐ๊ธฐ๋“ค์„ ๊ณจ๋ผ ๋‚ด๊ฐ€ ๋งŒ๋“  ์กฐํ•ฉ์— ๋‹ด์•„๋ณด์„ธ์š”! +

+
+ navigate('/devices')} + /> +
+
+
+
+ ); +}; + +export default CombinationResultOverlay; diff --git a/src/components/Combination/CombinationStyleProbe.tsx b/src/components/Combination/CombinationStyleProbe.tsx new file mode 100644 index 00000000..2cad435b --- /dev/null +++ b/src/components/Combination/CombinationStyleProbe.tsx @@ -0,0 +1,16 @@ +import { forwardRef } from 'react'; + +const CombinationStyleProbe = forwardRef((_, ref) => { + return ( +
+ probe +
+ ); +}); + +CombinationStyleProbe.displayName = 'CombinationStyleProbe'; + +export default CombinationStyleProbe; diff --git a/src/components/Combination/CombinationTag.tsx b/src/components/Combination/CombinationTag.tsx new file mode 100644 index 00000000..1bf549c5 --- /dev/null +++ b/src/components/Combination/CombinationTag.tsx @@ -0,0 +1,41 @@ +import { type CombinationName, type CombinationStatus } from '@/constants/combination'; + +type CombinationTagProps = { + name: CombinationName; + status: CombinationStatus; + className?: string; +}; + +const NAME_STYLE_MAP: Record = { + ์—ฐ๋™์„ฑ: 'bg-blue-200 text-blue-700', + ํŽธ์˜์„ฑ: 'bg-light-green text-dark-green', + ๋ผ์ดํ”„์Šคํƒ€์ผ: 'bg-light-yellow text-dark-yellow', +}; + +const STATUS_STYLE_MAP: Record = { + ์ตœ์ : 'font-caption-sm text-optimal', + ์–‘ํ˜ธ: 'font-caption-sm text-good', + ๋ณดํ†ต: 'font-caption-sm text-normal', + ๋ฏธํก: 'font-caption-sm text-poor', + '-': 'font-caption-sm text-optimal', +}; + +const CombinationTag = ({ name, status, className = '' }: CombinationTagProps) => { + return ( + + {name}: + {status} + + ); +}; + +export default CombinationTag; diff --git a/src/components/Combination/Stage1Section.tsx b/src/components/Combination/Stage1Section.tsx new file mode 100644 index 00000000..0aa0ce11 --- /dev/null +++ b/src/components/Combination/Stage1Section.tsx @@ -0,0 +1,21 @@ +import Stage1 from '@/assets/images/combination/stage1.svg?react'; + +const Stage1Section = () => { + return ( +
+
+
+

1๋‹จ๊ณ„

+

+ ๋‚˜๋งŒ์˜ ๊ธฐ๊ธฐ ์กฐํ•ฉ์„ ๋งŒ๋“ค์–ด๋ณด์„ธ์š”!
์กฐํ•ฉ๋ช…์„ ์ž…๋ ฅํ•˜๊ณ , ์กฐํ•ฉ ์ƒ์„ฑํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด +
+ ๋‚˜๋งŒ์˜ ์กฐํ•ฉ์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. +

+
+ +
+
+ ); +}; + +export default Stage1Section; diff --git a/src/components/Combination/Stage2Section.tsx b/src/components/Combination/Stage2Section.tsx new file mode 100644 index 00000000..1fb92a51 --- /dev/null +++ b/src/components/Combination/Stage2Section.tsx @@ -0,0 +1,22 @@ +import Stage2 from '@/assets/images/combination/stage2.svg?react'; + +const Stage2Section = () => { + return ( +
+
+
+

2๋‹จ๊ณ„

+

+ ๊ธฐ๊ธฐ๊ฒ€์ƒ‰ ์ฐฝ์—์„œ ์›ํ•˜๋Š” ๊ธฐ๊ธฐ๋“ค์„ ๊ณจ๋ผ +
๋‚ด๊ฐ€ ๋งŒ๋“  ์กฐํ•ฉ์— ๋‹ด์•„๋ณด์„ธ์š”! +
์ด๋•Œ ํ•œ ์กฐํ•ฉ์—๋Š” ๋™์ผํ•œ ์นดํ…Œ๊ณ ๋ฆฌ์˜ ๊ธฐ๊ธฐ๋ฅผ
+ ํ•˜๋‚˜์”ฉ๋งŒ ๋‹ด๋Š” ๊ฒƒ์„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. +

+
+ +
+
+ ); +}; + +export default Stage2Section; diff --git a/src/components/Combination/Stage3Section.tsx b/src/components/Combination/Stage3Section.tsx new file mode 100644 index 00000000..3e57549e --- /dev/null +++ b/src/components/Combination/Stage3Section.tsx @@ -0,0 +1,19 @@ +import Stage3 from '@/assets/images/combination/stage3.svg?react'; + +const Stage3Section = () => { + return ( +
+
+
+

3๋‹จ๊ณ„

+

+ ์šฐ์ธก ์ƒ๋‹จ MY์— ๋“ค์–ด๊ฐ€์„œ
๋‚ด๊ฐ€ ๋งŒ๋“  ์กฐํ•ฉ์˜ ์กฐํ•ฉ๋„๋ฅผ ํ™•์ธํ•ด ๋ณด์„ธ์š”! +

+
+ +
+
+ ); +}; + +export default Stage3Section; diff --git a/src/components/DeviceSearch/CombinationDetailModal.tsx b/src/components/DeviceSearch/CombinationDetailModal.tsx new file mode 100644 index 00000000..0dbfbe7e --- /dev/null +++ b/src/components/DeviceSearch/CombinationDetailModal.tsx @@ -0,0 +1,130 @@ +import type { ComboListItem, ComboDevice } from '@/types/combo/combo'; +import CombinationDeviceCard from '@/components/Combination/CombinationDeviceCard'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import BackIcon from '@/assets/icons/back.svg?react'; +import XIcon from '@/assets/icons/X.svg?react'; + +interface CombinationDetailModalProps { + combination: ComboListItem; + devices: ComboDevice[]; + comboIndex: number; + showAllDevices: boolean; + onExpandChange: (expanded: boolean) => void; + isAlreadyInCombination: boolean | 0 | null; + duplicateReason: 'model' | 'category' | null; + isAddingDevice: boolean; + onAddDevice: () => void; + onBack: () => void; + onClose: () => void; +} + +const CombinationDetailModal = ({ + combination, + devices, + comboIndex, + showAllDevices, + onExpandChange, + isAlreadyInCombination, + duplicateReason, + isAddingDevice, + onAddDevice, + onBack, + onClose, +}: CombinationDetailModalProps) => { + // ์ค‘๋ณต ์ด์œ ์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ + const getButtonText = () => { + if (!isAlreadyInCombination) { + return `${combination.comboName}์— ๋‹ด๊ธฐ`; + } + + if (duplicateReason === 'model') { + return '์ด๋ฏธ ๋‹ด์€ ๊ธฐ๊ธฐ์ž…๋‹ˆ๋‹ค.'; + } + + if (duplicateReason === 'category') { + return '์ด๋ฏธ ๋‹ด์€ ํƒ€์ž…์ž…๋‹ˆ๋‹ค.'; + } + + return '์ด๋ฏธ ๋‹ด์€ ํƒ€์ž…์ž…๋‹ˆ๋‹ค.'; + }; + return ( +
+ {/* Header: Back + X ๋ฒ„ํŠผ */} +
+ + +
+ + {/* Card */} +
e.stopPropagation()} + > + {/* ์กฐํ•ฉ ์ •๋ณด + ๊ธฐ๊ธฐ ๊ทธ๋ฆฌ๋“œ */} + onExpandChange(value)} + showExpandButton={false} + showGradient={true} + className="px-56 pt-40 pb-0 flex-shrink-0" + index={comboIndex} + /> + + {/* ํ† ๊ธ€ ๋ฒ„ํŠผ - CombinationDeviceCard ์™ธ๋ถ€์— ๋ฐฐ์น˜ */} + {devices.length > 9 && !showAllDevices && ( + + )} + {devices.length > 9 && showAllDevices && ( + + )} + + {/* ๋ฒ„ํŠผ ์ปจํ…Œ์ด๋„ˆ - ํ† ๊ธ€ ๋ฒ„ํŠผ์œผ๋กœ๋ถ€ํ„ฐ ๊ฐ„๊ฒฉ ์œ ์ง€ํ•˜๋ฉฐ ํ•˜๋‹จ ๊ณ ์ • */} +
+
+ +
+
+
+
+ ); +}; + +export default CombinationDetailModal; diff --git a/src/components/DeviceSearch/CombinationSelectModal.tsx b/src/components/DeviceSearch/CombinationSelectModal.tsx new file mode 100644 index 00000000..4c9a1294 --- /dev/null +++ b/src/components/DeviceSearch/CombinationSelectModal.tsx @@ -0,0 +1,124 @@ +import type { ComboListItem } from '@/types/combo/combo'; +import type { CombinationStatus } from '@/constants/combination'; +import CombinationTag from '@/components/Combination/CombinationTag'; +import BackIcon from '@/assets/icons/back.svg?react'; +import XIcon from '@/assets/icons/X.svg?react'; +import StarIcon from '@/assets/icons/star.svg?react'; +import MoreIcon from '@/assets/icons/more.svg?react'; +import { useComboEvaluation } from '@/apis/combo/getComboEvaluation'; + +interface CombinationSelectModalProps { + combos: ComboListItem[]; + onSelectCombination: (comboId: number) => void; + onBack: () => void; + onClose: () => void; +} + +/** + * ๊ฐœ๋ณ„ ์กฐํ•ฉ ์•„์ดํ…œ ์ปดํฌ๋„ŒํŠธ + * ๋งˆ์ดํŽ˜์ด์ง€ ์ƒ์„ธ ์ •๋ณด์™€ ๋™์ผํ•œ ํ‰๊ฐ€ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์™€์„œ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. + */ +const CombinationSelectItem = ({ + comboItem, + index, + onSelect, +}: { + comboItem: ComboListItem; + index: number; + onSelect: (id: number) => void; +}) => { + // ๊ฐ ์กฐํ•ฉ์˜ ํ‰๊ฐ€ ์ •๋ณด๋ฅผ ์ƒ์„ธ API์—์„œ ๊ฐ€์ ธ์˜ด (๋งˆ์ดํŽ˜์ด์ง€์™€ ๋™์ผํ•œ ๋ฐ์ดํ„ฐ ์†Œ์Šค) + const { data: evaluation } = useComboEvaluation(comboItem.comboId); + + return ( + + ); +}; + +const CombinationSelectModal = ({ + combos, + onSelectCombination, + onBack, + onClose, +}: CombinationSelectModalProps) => { + return ( +
+
+ + +
+ + {/* Card */} +
e.stopPropagation()} + > + {/* Combination List */} +
+ {combos.map((comboItem, index) => ( + + ))} +
+
+
+ ); +}; + +export default CombinationSelectModal; + + diff --git a/src/components/DeviceSearch/DeviceDetailModal.tsx b/src/components/DeviceSearch/DeviceDetailModal.tsx new file mode 100644 index 00000000..f7fc1d21 --- /dev/null +++ b/src/components/DeviceSearch/DeviceDetailModal.tsx @@ -0,0 +1,157 @@ +import type { Product } from '@/types/product'; +import type { SearchDevice } from '@/types/devices'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import XIcon from '@/assets/icons/X.svg?react'; +import RoundedLifestyleTag from '@/components/Lifestyle/RoundedLifestyleTag'; +import { getDeviceLifestyleTags } from '@/utils/tag/deviceLifestyleTags'; + +interface DeviceDetailModalProps { + product: Product; + device: SearchDevice; + addToCombinationConfig: { + text: string; + handler: () => void; + disabled?: boolean; + }; + isProfileLoading: boolean; + onClose: () => void; +} + +const DeviceDetailModal = ({ + product, + device, + addToCombinationConfig, + isProfileLoading, + onClose, +}: DeviceDetailModalProps) => { + const rawTags: string[] = getDeviceLifestyleTags(device); + + return ( +
+ {/* Close Button - ์นด๋“œ ๋ฐ”๊นฅ */} + + + {/* Card */} +
e.stopPropagation()} + > + {/* Content */} +
+ {/* Row 1: Name & Price */} +
+ {/* Name & Price */} +
+

{product.name}

+
+

โ‚ฉ

+

{(product.price ?? 0).toLocaleString()}

+
+
+
+ + {/* Row 2: Image + Specs */} +
+ {/* Image */} +
+ {product.image ? ( + {product.name} + ) : null} +
+ + {/* Right Section - Specs */} +
+ {/* Product Info Table */} +
+
+

๋ชจ๋ธ๋ช…

+

{product.name}

+
+
+

์นดํ…Œ๊ณ ๋ฆฌ

+

{product.category}

+
+ {device?.brandName && ( +
+

๋ธŒ๋žœ๋“œ

+

{device.brandName}

+
+ )} +
+

๊ฐ€๊ฒฉ

+
+

{(product.price ?? 0).toLocaleString()}

+

์›

+
+
+ {device?.specifications?.screenInch ? ( +
+

์ธ์น˜

+

+ {String(device.specifications.screenInch)} +

+
+ ) : null} + {device?.specifications?.chargingPort ? ( +
+

์ถฉ์ „๋ฐฉ์‹

+

+ {String(device.specifications.chargingPort).replace('_', '-')} +

+
+ ) : null} + {device?.releaseDate && ( +
+

์ถœ์‹œ์ผ

+

+ {new Date(device.releaseDate).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long' })} +

+
+ )} +
+ + {/* Lifestyle Tags */} + {rawTags.length > 0 && ( +
+ {rawTags.map((tag: string) => ( + + ))} +
+ )} +
+
+ + {/* Row 3: Button */} +
+ +
+
+
+
+ ); +}; + +export default DeviceDetailModal; diff --git a/src/components/DeviceSearch/SaveCompleteModal.tsx b/src/components/DeviceSearch/SaveCompleteModal.tsx new file mode 100644 index 00000000..9a9a469c --- /dev/null +++ b/src/components/DeviceSearch/SaveCompleteModal.tsx @@ -0,0 +1,21 @@ +import SaveIcon from '@/assets/icons/save.svg?react'; + +interface SaveCompleteModalProps { + isFadingOut: boolean; +} + +const SaveCompleteModal = ({ isFadingOut }: SaveCompleteModalProps) => { + return ( + <> +
+
+
+ +

์ €์žฅ ์™„๋ฃŒ!

+
+
+ + ); +}; + +export default SaveCompleteModal; diff --git a/src/components/Filter/FilterDropdown.tsx b/src/components/Filter/FilterDropdown.tsx new file mode 100644 index 00000000..a235cb60 --- /dev/null +++ b/src/components/Filter/FilterDropdown.tsx @@ -0,0 +1,151 @@ +import { useState, useRef, useEffect } from 'react'; +import CheckboxIcon from '@/assets/icons/checkbox.svg?react'; +import CheckboxOnIcon from '@/assets/icons/checkbox_on.svg?react'; +import DropdownIcon from '@/assets/icons/dropdown.svg?react'; +import type { FilterOption } from '@/constants/devices'; + +interface FilterDropdownProps { + label: string; + options: FilterOption[]; + selectedValue: string | string[] | null; + onSelect: (value: string | string[] | null) => void; + multiple?: boolean; +} + +const FilterDropdown = ({ + label, + options, + selectedValue, + onSelect, + multiple = false, +}: FilterDropdownProps) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + /* ๋“œ๋กญ๋‹ค์šด ์™ธ๋ถ€ ํด๋ฆญ ์ฒ˜๋ฆฌ */ + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const isSelected = (value: string) => { + if (multiple && Array.isArray(selectedValue)) { + return selectedValue.includes(value); + } + return selectedValue === value; + }; + + const hasSelection = multiple + ? Array.isArray(selectedValue) && selectedValue.length > 0 + : selectedValue !== null; + + const getSelectedLabel = () => { + if (multiple && Array.isArray(selectedValue) && selectedValue.length > 0) { + // options ์ˆœ์„œ์— ๋”ฐ๋ผ ์ •๋ ฌ (๊ฐ€๊ฒฉ ์ˆœ์„œ ๋“ฑ ์›๋ž˜ ์ •์˜๋œ ์ˆœ์„œ ์œ ์ง€) + const sortedValues = [...selectedValue].sort((a, b) => { + const indexA = options.findIndex(opt => opt.value === a); + const indexB = options.findIndex(opt => opt.value === b); + return indexA - indexB; + }); + + if (sortedValues.length === 1) { + return options.find(opt => opt.value === sortedValues[0])?.label || label; + } + return `${options.find(opt => opt.value === sortedValues[0])?.label} ์™ธ ${sortedValues.length - 1}๊ฐœ`; + } + if (!multiple && selectedValue && typeof selectedValue === 'string') { + return options.find(opt => opt.value === selectedValue)?.label || label; + } + return label; + }; + + const handleSelect = (value: string) => { + if (multiple) { + const currentValues = Array.isArray(selectedValue) ? selectedValue : []; + if (currentValues.includes(value)) { + const newValues = currentValues.filter(v => v !== value); + onSelect(newValues.length > 0 ? newValues : []); + } else { + onSelect([...currentValues, value]); + } + } else { + onSelect(selectedValue === value ? null : value); + setIsOpen(false); + } + }; + + return ( +
+ + + {isOpen && ( +
+ {options.map((option, index) => ( + + ))} +
+ )} +
+ ); +}; + +export default FilterDropdown; diff --git a/src/components/Filter/SortDropdown.tsx b/src/components/Filter/SortDropdown.tsx new file mode 100644 index 00000000..d6fdae62 --- /dev/null +++ b/src/components/Filter/SortDropdown.tsx @@ -0,0 +1,84 @@ +import { useState, useRef, useEffect } from 'react'; +import DropdownIcon from '@/assets/icons/dropdown.svg?react'; +import type { FilterOption } from '@/constants/devices'; + +interface SortDropdownProps { + options: FilterOption[]; + selectedValue: string; + onSelect: (value: string) => void; +} + +const SortDropdown = ({ + options, + selectedValue, + onSelect, +}: SortDropdownProps) => { + const [isOpen, setIsOpen] = useState(false); + const [hoveredIndex, setHoveredIndex] = useState(null); + const dropdownRef = useRef(null); + + /* ๋“œ๋กญ๋‹ค์šด ์™ธ๋ถ€ ํด๋ฆญ ์ฒ˜๋ฆฌ */ + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const selectedLabel = options.find(opt => opt.value === selectedValue)?.label; + + return ( +
+ + + {isOpen && ( +
setHoveredIndex(null)} + > + {options.map((option, index) => ( + + ))} +
+ )} +
+ ); +}; + +export default SortDropdown; diff --git a/src/components/Home/ConnectivitySection.tsx b/src/components/Home/ConnectivitySection.tsx new file mode 100644 index 00000000..c0093264 --- /dev/null +++ b/src/components/Home/ConnectivitySection.tsx @@ -0,0 +1,18 @@ +import Connectivity from '@/assets/icons/connectivity.svg?react'; + +const ConnectivitySection = () => { + return ( +
+
+

์—ฐ๋™์„ฑ

+

+ OS ๋ฐ ์ œ์กฐ์‚ฌ ์ƒํƒœ๊ณ„๋ฅผ ๋ถ„์„ํ•˜์—ฌ, ๊ธฐ๊ธฐ ๊ฐ„์˜ ๋Š๊น€ ์—†๋Š”
์—ฐ๊ฒฐ๊ณผ ์†Œํ”„ํŠธ์›จ์–ด ํ˜ธํ™˜์„ฑ์„ + ์ •๋ฐ€ํ•˜๊ฒŒ ์ง„๋‹จํ•ฉ๋‹ˆ๋‹ค. +

+
+ +
+ ); +}; + +export default ConnectivitySection; diff --git a/src/components/Home/Footer.tsx b/src/components/Home/Footer.tsx new file mode 100644 index 00000000..ebf45d5d --- /dev/null +++ b/src/components/Home/Footer.tsx @@ -0,0 +1,63 @@ +const Footer = () => { + return ( + + ); +}; + +export default Footer; diff --git a/src/components/Home/GNB.tsx b/src/components/Home/GNB.tsx new file mode 100644 index 00000000..5fd54cc4 --- /dev/null +++ b/src/components/Home/GNB.tsx @@ -0,0 +1,99 @@ +import Logo from '@/assets/logos/logo.svg?react'; +import UserBlack from '@/assets/icons/userblack.svg?react'; +import UserBlue500 from '@/assets/icons/userblue500.svg?react'; +import UserBlue600 from '@/assets/icons/userblue600.svg?react'; +import { NavLink } from 'react-router-dom'; +import clsx from 'clsx'; +import { useLogout } from '@/hooks/useLogout'; +import { useAuth } from '@/hooks/useAuth'; +import { ROUTES } from '@/constants/routes'; + +const NAV_TEXT_CLASS = 'font-body-1-sm text-black hover:text-blue-500 active:text-blue-600 cursor-pointer'; +const USER_BLUE_500_CLASS = 'absolute inset-0 opacity-0 group-hover:opacity-100'; + +interface GNBProps { + paddingRight?: number; +} +const getNavClass = ({ isActive }: { isActive: boolean }) => + clsx(NAV_TEXT_CLASS, isActive && 'text-blue-600 hover:text-blue-500'); + +const getMyNavLinkClass = ({ isActive }: { isActive: boolean }) => + clsx( + 'group flex items-center gap-4', + isActive ? 'font-body-1-sm text-blue-600 hover:text-blue-500' : NAV_TEXT_CLASS + ); + +const getUserBlackClass = (isActive: boolean) => + clsx('absolute inset-0', isActive ? 'opacity-0' : 'opacity-100 group-hover:opacity-0'); + +const getUserBlue600Class = (isActive: boolean) => + clsx('absolute inset-0', isActive ? 'opacity-100' : 'opacity-0 group-active:opacity-100'); + +const GNB = ({ paddingRight: _paddingRight = 0 }: GNBProps) => { + const { isLoggedIn } = useAuth(); + const { logout } = useLogout(); + + const handleLogout = async () => { + await logout(); + }; + + return ( +
+
+
+
+
+ + + Device Life + + + ๊ธฐ๊ธฐ๊ฒ€์ƒ‰ + + + ๋ผ์ดํ”„์Šคํƒ€์ผ + + + ์กฐํ•ฉ ์ƒ์„ฑํ•˜๊ธฐ + +
+
+ {isLoggedIn ? ( + // ๋กœ๊ทธ์ธ ์ƒํƒœ: MY | ๋กœ๊ทธ์•„์›ƒ + <> + + {({ isActive }) => ( + <> + + + + + + MY + + )} + + + + ) : ( + // ๋น„๋กœ๊ทธ์ธ ์ƒํƒœ: ๋กœ๊ทธ์ธ | ํšŒ์›๊ฐ€์ž… + <> + + ๋กœ๊ทธ์ธ + + + ํšŒ์›๊ฐ€์ž… + + + )} +
+
+
+
+
+ ); +}; + +export default GNB; diff --git a/src/components/Home/LifestyleSection.tsx b/src/components/Home/LifestyleSection.tsx new file mode 100644 index 00000000..dee4c913 --- /dev/null +++ b/src/components/Home/LifestyleSection.tsx @@ -0,0 +1,18 @@ +import Lifestyle from '@/assets/icons/lifestyle.svg?react'; + +const LifeStyleSection = () => { + return ( +
+
+

๋ผ์ดํ”„์Šคํƒ€์ผ

+

+ ์‚ฌ์šฉ์ž์˜ ๋ผ์ดํ”„์Šคํƒ€์ผ์— ๋งž์ถฐ
+ ๊ฐ€์žฅ ์ค‘์š”ํ•œ ๊ธฐ์ค€์„ ์šฐ์„ ์ ์œผ๋กœ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค. +

+
+ +
+ ); +}; + +export default LifeStyleSection; diff --git a/src/components/Home/LogicEvaluationSection.tsx b/src/components/Home/LogicEvaluationSection.tsx new file mode 100644 index 00000000..da02d3cf --- /dev/null +++ b/src/components/Home/LogicEvaluationSection.tsx @@ -0,0 +1,18 @@ +import LogicEvaluation from '@/assets/icons/logicevaluation.svg?react'; + +const LogicEvaluationSection = () => { + return ( +
+
+

๋กœ์ง ํ‰๊ฐ€

+

+ Device Life์˜ ์ž์ฒด ๋กœ์ง์„ ํ†ตํ•ด, +
๊ธฐ๊ธฐ ๊ฐ„์˜ ์กฐํ•ฉ ์ ํ•ฉ์„ฑ์„ ๋น ๋ฅด๊ณ  ์ •ํ™•ํ•˜๊ฒŒ ํŒ๋‹จํ•ฉ๋‹ˆ๋‹ค. +

+
+ +
+ ); +}; + +export default LogicEvaluationSection; diff --git a/src/components/Home/PortabilitySection.tsx b/src/components/Home/PortabilitySection.tsx new file mode 100644 index 00000000..ea29a157 --- /dev/null +++ b/src/components/Home/PortabilitySection.tsx @@ -0,0 +1,18 @@ +import Portability from '@/assets/icons/portability.svg?react'; + +const PortabilitySection = () => { + return ( +
+
+

ํŽธ์˜์„ฑ

+

+ ๊ธฐ๊ธฐ์˜ ๋ฌด๊ฒŒ, ํฌ๊ธฐ ๋ฐ ๋ฐฐํ„ฐ๋ฆฌ ํšจ์œจ์„ ์ข…ํ•ฉ์ ์œผ๋กœ ๋ถ„์„ํ•˜์—ฌ,
+ ์‹ค์งˆ์ ์ธ ํœด๋Œ€ ๋ถ€๋‹ด๊ณผ ์‚ฌ์šฉ ์ง€์†์„ฑ์„ ํŒ๋‹จํ•ด ์ค๋‹ˆ๋‹ค. +

+
+ +
+ ); +}; + +export default PortabilitySection; diff --git a/src/components/Input/PrimaryInput.tsx b/src/components/Input/PrimaryInput.tsx new file mode 100644 index 00000000..6271343b --- /dev/null +++ b/src/components/Input/PrimaryInput.tsx @@ -0,0 +1,30 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; +import { cn } from '@/utils/cn'; + +type PrimaryInputProps = InputHTMLAttributes; + +const PrimaryInput = forwardRef( + // ๊ธฐ๋ณธ๊ฐ’ ์„ค์ • + ({ type = 'text', disabled = false, className = '', ...rest }, ref) => { + // ์ž…๋ ฅ์ฐฝ ์š”์†Œ ์ƒ์„ฑ + return ( + + ); + } +); + +PrimaryInput.displayName = 'PrimaryInput'; + +export default PrimaryInput; diff --git a/src/components/Lifestyle/DeviceSummaryCard.tsx b/src/components/Lifestyle/DeviceSummaryCard.tsx new file mode 100644 index 00000000..6d9a4cf1 --- /dev/null +++ b/src/components/Lifestyle/DeviceSummaryCard.tsx @@ -0,0 +1,37 @@ +import type { LifestyleDevice } from '@/types/lifestyle/lifestyle'; + +type Props = { + device?: LifestyleDevice; +}; + +const DeviceSummaryCard = ({ device }: Props) => { + return ( +
+
+ {device?.imageUrl ? ( + {device.displayName} + ) : null} +
+
+
+

+ {device?.displayName ?? '-'} +

+
+

+ {device?.releaseDate ?? '-'} +

+

+ {device?.price != null ? `โ‚ฉ ${device.price.toLocaleString()}` : '-'} +

+
+
+ ); +}; + +export default DeviceSummaryCard; diff --git a/src/components/Lifestyle/LifestyleTag.tsx b/src/components/Lifestyle/LifestyleTag.tsx new file mode 100644 index 00000000..7eaca0ca --- /dev/null +++ b/src/components/Lifestyle/LifestyleTag.tsx @@ -0,0 +1,45 @@ +type LifestyleTagProps = { + label: string; + selected?: boolean; + onClick?: () => void; +}; + +const LifestyleTag = ({ label, selected = false, onClick }: LifestyleTagProps) => { + const hasSlash = label.includes('/'); + let content: React.ReactNode = label; + + if (hasSlash) { + const [left, right] = label.split('/'); + content = ( + + {left} + + / + + {right} + + ); + } + + return ( + + ); +}; + +export default LifestyleTag; diff --git a/src/components/Lifestyle/OnboardingLifestyleTag.tsx b/src/components/Lifestyle/OnboardingLifestyleTag.tsx new file mode 100644 index 00000000..4b49c575 --- /dev/null +++ b/src/components/Lifestyle/OnboardingLifestyleTag.tsx @@ -0,0 +1,34 @@ +type OnboardingLifestyleTagProps = { + label: string; + selected?: boolean; + onClick?: () => void; + className?: string; +}; + +const OnboardingLifestyleTag = ({ + label, + selected = false, + onClick, + className = '', +}: OnboardingLifestyleTagProps) => { + return ( + + ); +}; + +export default OnboardingLifestyleTag; diff --git a/src/components/Lifestyle/RoundedLifestyleTag.tsx b/src/components/Lifestyle/RoundedLifestyleTag.tsx new file mode 100644 index 00000000..6800476f --- /dev/null +++ b/src/components/Lifestyle/RoundedLifestyleTag.tsx @@ -0,0 +1,42 @@ +type RoundedLifestyleTagProps = + | { label: string } + | { + label: string; + selected: boolean; + onToggle: (label: string, nextSelected: boolean) => void; + }; + +const RoundedLifestyleTag = (props: RoundedLifestyleTagProps) => { + const isInteractive = 'onToggle' in props; + + const handleClick = () => { + if (!isInteractive) return; + props.onToggle(props.label, !props.selected); + }; + + const opacityClass = isInteractive + ? props.selected + ? 'opacity-100' + : 'opacity-50' + : 'opacity-100'; + const cursorClass = isInteractive ? 'cursor-pointer' : 'cursor-default'; + + return ( +
+ {props.label} +
+ ); +}; + +export default RoundedLifestyleTag; diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx new file mode 100644 index 00000000..5fa753b1 --- /dev/null +++ b/src/components/LoadingSpinner.tsx @@ -0,0 +1,11 @@ +import { ClipLoader } from 'react-spinners'; + +const LoadingSpinner = () => { + return ( +
+ +
+ ); +}; + +export default LoadingSpinner; diff --git a/src/components/MyPage/CombinationCard.tsx b/src/components/MyPage/CombinationCard.tsx new file mode 100644 index 00000000..5598fb79 --- /dev/null +++ b/src/components/MyPage/CombinationCard.tsx @@ -0,0 +1,202 @@ +import { useState, memo } from 'react'; +import StarIcon from '@/assets/icons/star.svg?react'; +import StarXIcon from '@/assets/icons/starx.svg?react'; +import StarHoverIcon from '@/assets/icons/starhover.svg?react'; +import CombinationTag from '@/components/Combination/CombinationTag'; +import { useComboEvaluation } from '@/apis/combo/getComboEvaluation'; +import { formatDate } from '@/utils/format'; +import type { ComboListItem } from '@/types/combo/combo'; +import type { CombinationStatus } from '@/constants/combination'; + +interface CombinationCardProps { + combination: ComboListItem; + index: number; + columns: 3 | 4; + isEditing: boolean; + editingName: string; + nameError: string | null; + onTogglePin: (e: React.MouseEvent, comboId: number) => void; + onNameChange: (e: React.ChangeEvent) => void; + onNameBlur: (name: string) => void; +} + +const CombinationCard = ({ + combination, + index, + columns, + isEditing, + editingName, + nameError, + onTogglePin, + onNameChange, + onNameBlur, +}: CombinationCardProps) => { + const [hoveredStarComboId, setHoveredStarComboId] = useState(null); + + // ์กฐํ•ฉ ํ‰๊ฐ€ ์บ์‹œ ๊ตฌ๋… (staleTime: Infinity์ด๋ฏ€๋กœ ์บ์‹œ์— ์žˆ์œผ๋ฉด API ํ˜ธ์ถœ ์—†์ด ๋ฐ”๋กœ ์‚ฌ์šฉ) + const { data: evaluation } = useComboEvaluation(combination.comboId); + + // ๊ทธ๋ผ๋ฐ์ด์…˜ ๋กœ์ง + const gradientThreshold = columns === 4 ? 9 : 7; + const shouldShowGradient = combination.devices.length >= gradientThreshold; + const maxDisplay = columns === 4 ? 8 : 6; + const displayedDevices = shouldShowGradient + ? combination.devices.slice(0, maxDisplay) + : combination.devices; + + return ( +
+ {/* ์กฐํ•ฉ ์ •๋ณด (์ƒ์„ฑ์ผ ํฌํ•จ) */} +
+ {isEditing ? ( + /* ์ˆ˜์ • ๋ชจ๋“œ: ์ธํ’‹๋ฐ•์Šค + ๋ณ„ ์•„์ด์ฝ˜ + ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ */ +
+
+ e.stopPropagation()} + onBlur={() => onNameBlur(editingName)} + maxLength={20} + className={`h-52 px-12 rounded-button font-body-1-sm text-gray-300 focus:outline-none ${ + nameError ? 'border-2 border-warning' : 'border border-blue-600' + }`} + autoFocus + /> + {combination.isPinned ? ( + onTogglePin(e, combination.comboId)} + className={`!w-22 !h-22 -mt-2 cursor-pointer transition-opacity ${ + hoveredStarComboId === combination.comboId ? 'opacity-80' : '' + }`} + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + <> + {hoveredStarComboId === combination.comboId ? ( + onTogglePin(e, combination.comboId)} + className="!w-22 !h-22 -mt-2 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + onTogglePin(e, combination.comboId)} + className="!w-22 !h-22 -mt-2 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + )} + + )} +
+ {nameError &&

{nameError}

} +
+ ) : ( + /* ์ผ๋ฐ˜ ๋ชจ๋“œ: ์กฐํ•ฉ ๋ฒˆํ˜ธ + ์ƒ์„ฑ์ผ + ์กฐํ•ฉ๋ช… + ํƒœ๊ทธ */ +
+
+
+

์กฐํ•ฉ{index + 1}

+

+ ์ƒ์„ฑ์ผ: {formatDate(combination.createdAt)} +

+
+
+

{combination.comboName}

+ {combination.isPinned ? ( + onTogglePin(e, combination.comboId)} + className={`!w-22 !h-22 -mt-3 cursor-pointer transition-opacity ${ + hoveredStarComboId === combination.comboId ? 'opacity-80' : '' + }`} + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + <> + {hoveredStarComboId === combination.comboId ? ( + onTogglePin(e, combination.comboId)} + className="!w-22 !h-22 -mt-3 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + onTogglePin(e, combination.comboId)} + className="!w-22 !h-22 -mt-3 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + )} + + )} +
+
+ {/* ์กฐํ•ฉ ํ‰๊ฐ€ ํƒœ๊ทธ - COMBO_EVALUATION ์บ์‹œ์—์„œ ๋“ฑ๊ธ‰ ์ฝ๊ธฐ */} +
+ + + +
+
+ )} +
+ + {/* ๊ธฐ๊ธฐ ๊ทธ๋ฆฌ๋“œ - ์ผ๋ฐ˜ ๋ชจ๋“œ์—์„œ๋„ ๊ธฐ๊ธฐ ์นด๋“œ ํ‘œ์‹œ (๊ทธ๋ผ๋ฐ์ด์…˜ ํฌํ•จ) */} +
+
+ {displayedDevices.map((device) => ( +
+
+ {device.imageUrl && ( + {device.name} + )} +
+
+

{device.name}

+

{device.brandName}

+

{device.deviceType}

+
+
+ ))} +
+ + {/* ๊ทธ๋ผ๋ฐ์ด์…˜ ์˜ค๋ฒ„๋ ˆ์ด */} + {shouldShowGradient && ( +
+ )} +
+
+ ); +}; + +export default memo(CombinationCard); diff --git a/src/components/MyPage/CombinationDeleteModal.tsx b/src/components/MyPage/CombinationDeleteModal.tsx new file mode 100644 index 00000000..afd8a4d9 --- /dev/null +++ b/src/components/MyPage/CombinationDeleteModal.tsx @@ -0,0 +1,62 @@ +import { memo } from 'react'; +import RemoveIcon from '@/assets/icons/remove.svg?react'; + +interface CombinationDeleteModalProps { + comboName: string; + isDeleting: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +const CombinationDeleteModal = ({ + comboName, + isDeleting, + onConfirm, + onCancel, +}: CombinationDeleteModalProps) => { + return ( + <> + {/* ๋ฐฐ๊ฒฝ ์˜ค๋ฒ„๋ ˆ์ด */} +
+ + {/* ๋ชจ๋‹ฌ */} +
+
e.stopPropagation()} + > + {/* ์•„์ด์ฝ˜ */} + + + {/* ํ…์ŠคํŠธ */} +

+ '{comboName}'์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? +

+ + {/* ๋ฒ„ํŠผ ๊ทธ๋ฃน */} +
+ + +
+
+
+ + ); +}; + +export default memo(CombinationDeleteModal); diff --git a/src/components/MyPage/CombinationDetailView.tsx b/src/components/MyPage/CombinationDetailView.tsx new file mode 100644 index 00000000..3e1b3a26 --- /dev/null +++ b/src/components/MyPage/CombinationDetailView.tsx @@ -0,0 +1,252 @@ +import { useState, memo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import BackIcon from '@/assets/icons/back.svg?react'; +import StarIcon from '@/assets/icons/star.svg?react'; +import StarXIcon from '@/assets/icons/starx.svg?react'; +import StarHoverIcon from '@/assets/icons/starhover.svg?react'; +import CheckboxIcon from '@/assets/icons/checkbox.svg?react'; +import CheckboxOnIcon from '@/assets/icons/checkbox_on.svg?react'; +import TrashIcon from '@/assets/icons/trash.svg?react'; +import PlusIcon from '@/assets/icons/plus.svg?react'; +import CombinationEvaluationCard from '@/components/Combination/CombinationEvaluationCard'; +import { formatDate } from '@/utils/format'; +import type { ComboListItem } from '@/types/combo/combo'; +import type { CombinationName } from '@/constants/combination'; +import type { Grade } from '@/constants/evaluation/grade'; + +interface Device { + deviceId: number; + name: string; + brandName?: string; + deviceType?: string; + imageUrl?: string; +} + +interface EvaluationCard { + category: string; + grade: string; + text: string; + tags: string[]; +} + +interface CombinationDetailViewProps { + combination: ComboListItem; + index: number; + devices: Device[]; + deviceIds: number[]; + columns: 3 | 4; + selectedDevices: number[]; + totalPrice: number; + evaluationCards: EvaluationCard[] | null; + isEvaluationLoading: boolean; + onBack: () => void; + onTogglePin: (e: React.MouseEvent, comboId: number) => void; + onSelectAll: (deviceIds: number[]) => void; + onSelectDevice: (deviceId: number) => void; + onTrashClick: () => void; + onSaveScrollBeforeNavigate: () => void; +} + +const CombinationDetailView = ({ + combination, + index, + devices, + deviceIds, + columns, + selectedDevices, + totalPrice, + evaluationCards, + isEvaluationLoading, + onBack, + onTogglePin, + onSelectAll, + onSelectDevice, + onTrashClick, + onSaveScrollBeforeNavigate, +}: CombinationDetailViewProps) => { + const navigate = useNavigate(); + const [hoveredStarComboId, setHoveredStarComboId] = useState(null); + + return ( +
e.stopPropagation()}> + {/* ๋’ค๋กœ๊ฐ€๊ธฐ ๋ฒ„ํŠผ */} +
+ +
+ + {/* ์กฐํ•ฉ ์ •๋ณด */} +
+
+
+

์กฐํ•ฉ{index + 1}

+

+ ์ƒ์„ฑ์ผ: {formatDate(combination.createdAt)} +

+
+
+

{combination.comboName}

+ {combination.isPinned ? ( + onTogglePin(e, combination.comboId)} + className={`!w-22 !h-22 -mt-2 cursor-pointer transition-opacity ${ + hoveredStarComboId === combination.comboId ? 'opacity-80' : '' + }`} + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + <> + {hoveredStarComboId === combination.comboId ? ( + onTogglePin(e, combination.comboId)} + className="!w-22 !h-22 -mt-2 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + onTogglePin(e, combination.comboId)} + className="!w-22 !h-22 -mt-2 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + )} + + )} +
+
+
+ + {/* ์ „์ฒด ์„ ํƒ + ํœด์ง€ํ†ต */} +
+
+
+ +

์ „์ฒด ์„ ํƒํ•˜๊ธฐ

+
+ +
+
+ + {/* ์•ˆ๋‚ด ํ…์ŠคํŠธ */} +

+ *๋งˆ์šฐ์Šค๋ฅผ ๊ธฐ๊ธฐ ์œ„์— ์˜ฌ๋ ค์„œ ๊ธฐ๊ธฐ ์ƒ์„ธ์ •๋ณด๋ฅผ ํ™•์ธํ•˜์‹ค ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค. +

+ + {/* ๊ธฐ๊ธฐ ๊ทธ๋ฆฌ๋“œ (์ฒดํฌ๋ฐ•์Šค ํฌํ•จ) */} +
+
+ {devices.map((device) => ( +
window.open(`/devices?productId=${device.deviceId}`, '_blank')} + className={`bg-white rounded-card shadow-[0_0_4px_rgba(0,0,0,0.1)] p-12 w-244 flex items-center gap-12 border cursor-pointer hover:shadow-[0_0_7px_#57a0ff] transition-shadow ${selectedDevices.includes(device.deviceId) ? 'border-blue-600' : 'border-transparent'}`} + > +
+ {device.imageUrl && ( + {device.name} + )} + {/* ํ˜ธ๋ฒ„ ์˜ค๋ฒ„๋ ˆ์ด */} +
+ + ๋ณด๊ธฐ + +
+
+
+
+

{device.name}

+ {/* ์ฒดํฌ๋ฐ•์Šค - ๊ธฐ๊ธฐ๋ช…๊ณผ ๊ฐ™์€ ๋†’์ด */} + +
+

{device.brandName || '-'}

+

{device.deviceType || '-'}

+
+
+ ))} + {/* ๊ธฐ๊ธฐ ์ถ”๊ฐ€ ๋ฒ„ํŠผ */} + +
+
+ + {/* ์ด ๊ฐ€๊ฒฉ */} +
+
+

์ด ๊ฐ€๊ฒฉ

+
+

โ‚ฉ

+

{totalPrice.toLocaleString()}

+
+
+
+ + {/* ๊ตฌ๋ถ„์„  + ์กฐํ•ฉ ํ‰๊ฐ€ ์ •๋ณด (๋กœ๋”ฉ ์ค‘์—๋Š” ์ˆจ๊น€) */} + {!isEvaluationLoading && ( + <> +
+ +
+
+ {evaluationCards ? ( + evaluationCards.map((card) => ( + + )) + ) : ( +
+

+ ์กฐํ•ฉ ํ‰๊ฐ€ ์ •๋ณด๊ฐ€ ์•„์ง ์ค€๋น„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. +

+
+ )} +
+
+ + )} +
+ ); +}; + +export default memo(CombinationDetailView); diff --git a/src/components/MyPage/CombinationList.tsx b/src/components/MyPage/CombinationList.tsx new file mode 100644 index 00000000..34015ff1 --- /dev/null +++ b/src/components/MyPage/CombinationList.tsx @@ -0,0 +1,284 @@ +import { type RefObject, useState, useMemo } from 'react'; +import SecondaryButton from '@/components/Button/SecondaryButton'; +import SortDropdown from '@/components/Filter/SortDropdown'; +import CombinationMenu from '@/components/MyPage/CombinationMenu'; +import CombinationCard from '@/components/MyPage/CombinationCard'; +import EmptyCombinationCard from '@/components/MyPage/EmptyCombinationCard'; +import CombinationDetailView from '@/components/MyPage/CombinationDetailView'; +import SettingMoreIcon from '@/assets/icons/settingmore.svg?react'; +import AlarmIcon from '@/assets/icons/alarm.svg?react'; +import { MYPAGE_SORT_OPTIONS } from '@/constants/combination'; +import type { ComboListItem, GetComboResult } from '@/types/combo/combo'; +import type { UseCombinationEditReturn } from '@/hooks/useCombinationEdit'; +import type { UseCombinationModalsReturn } from '@/hooks/useCombinationModals'; +import type { UseDeviceSelectionReturn } from '@/hooks/useDeviceSelection'; +import type { EvaluationCardUI } from '@/utils/mapEvaluationToUI'; + +interface CombinationListProps { + combinationListRef: RefObject; + sortedCombos: ComboListItem[]; + isLoading: boolean; + isError: boolean; + detailViewComboId: number | null; + comboDetail: GetComboResult | undefined; + columns: 3 | 4; + sortOption: string; + setSortOption: (option: string) => void; + openMenuIndex: number | null; + setOpenMenuIndex: (index: number | null) => void; + menuRef: RefObject; + combinationEdit: UseCombinationEditReturn; + modals: UseCombinationModalsReturn; + deviceSelection: UseDeviceSelectionReturn; + evaluationCards: EvaluationCardUI[] | null; + isEvaluationLoading: boolean; + handleDetailView: (comboId: number) => void; + handleBackToNormal: () => void; + handleTogglePin: (e: React.MouseEvent, comboId: number) => void; + handleTrashClick: () => void; + handleSaveScrollBeforeNavigate: () => void; +} + +const CombinationList = ({ + combinationListRef, + sortedCombos, + isLoading, + isError, + detailViewComboId, + comboDetail, + columns, + sortOption, + setSortOption, + openMenuIndex, + setOpenMenuIndex, + menuRef, + combinationEdit, + modals, + deviceSelection, + evaluationCards, + isEvaluationLoading, + handleDetailView, + handleBackToNormal, + handleTogglePin, + handleTrashClick, + handleSaveScrollBeforeNavigate, +}: CombinationListProps) => { + // Lazy Loading: ์ดˆ๊ธฐ 12๊ฐœ, ๋” ๋ณด๊ธฐ ํด๋ฆญ ์‹œ 12๊ฐœ์”ฉ ์ถ”๊ฐ€ + const INITIAL_DISPLAY_COUNT = 12; + const LOAD_MORE_COUNT = 12; + const [displayCount, setDisplayCount] = useState(INITIAL_DISPLAY_COUNT); + + // ์‹ค์ œ ๋ Œ๋”๋งํ•  ์กฐํ•ฉ ๋ชฉ๋ก (Detail View๊ฐ€ ์•„๋‹ ๋•Œ๋งŒ Lazy Loading ์ ์šฉ) + const displayedCombos = useMemo(() => { + if (detailViewComboId !== null) { + // Detail View: ๋ชจ๋“  ์กฐํ•ฉ ํ‘œ์‹œ (ํ•„ํ„ฐ๋ง๋œ ์กฐํ•ฉ ์ฐพ๊ธฐ ์œ„ํ•ด) + return sortedCombos; + } + // Normal View: displayCount๋งŒํผ๋งŒ ํ‘œ์‹œ + return sortedCombos.slice(0, displayCount); + }, [sortedCombos, displayCount, detailViewComboId]); + + const hasMore = sortedCombos.length > displayCount && detailViewComboId === null; + + const handleLoadMore = () => { + setDisplayCount((prev) => prev + LOAD_MORE_COUNT); + }; + + return ( +
+ {isLoading && ( +
+

์กฐํ•ฉ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ )} + {isError && ( +
+

์กฐํ•ฉ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.

+
+ )} + {!isLoading && !isError && sortedCombos.length === 0 && ( +
+

๋“ฑ๋ก๋œ ์กฐํ•ฉ์ด ์—†์Šต๋‹ˆ๋‹ค.

+
+ )} + {displayedCombos.map((combination, index) => { + const isDetailView = detailViewComboId === combination.comboId; + const hasDevices = combination.deviceCount > 0; + const devices = isDetailView && comboDetail ? comboDetail.devices : []; + const deviceIds = devices.map((d) => d.deviceId); + + // ์ƒ์„ธ๋ณด๊ธฐ ๋ชจ๋“œ์ผ ๋•Œ ์„ ํƒ๋œ ์กฐํ•ฉ๋งŒ ํ‘œ์‹œ + if (detailViewComboId !== null && !isDetailView) { + return null; + } + + return ( +
+ {/* ์ถ”์ฒœ ๋ฉ”์‹œ์ง€ + ์ •๋ ฌ ํ•„ํ„ฐ - ์ƒ์„ธ๋ณด๊ธฐ ๋ชจ๋“œ๊ฐ€ ์•„๋‹ ๋•Œ๋งŒ ํ‘œ์‹œ */} + {!isDetailView && ( +
+
+ +

+ {hasDevices + ? '์ถ”์ฒœํ•˜๋Š” ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค. ๊ธฐ๊ธฐ ๊ฐ„ ํ˜ธํ™˜์„ฑ์ด ์šฐ์ˆ˜ํ•˜๋ฉฐ ๋งŒ์กฑ๋„๊ฐ€ ๋†’์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค.' + : '-'} +

+
+ {index === 0 && sortedCombos.length > 1 && ( + + )} +
+ )} + + {/* ์กฐํ•ฉ ์นด๋“œ */} +
+ !isDetailView && + combinationEdit.editingComboId !== combination.comboId && + hasDevices && + handleDetailView(combination.comboId) + } + className={`rounded-card relative ${ + isDetailView + ? 'bg-blue-100' + : `bg-white shadow-[0_0_4px_rgba(0,0,0,0.25)] transition-shadow ${hasDevices ? 'cursor-pointer hover:shadow-[0_0_12px_rgba(0,105,240,0.5)]' : ''}` + }`} + > + {/* ์ผ๋ฐ˜ ๋ชจ๋“œ: Setting More ๋ฒ„ํŠผ + ๋“œ๋กญ๋‹ค์šด ๋˜๋Š” ์ €์žฅํ•˜๊ธฐ ๋ฒ„ํŠผ */} + {!isDetailView && ( +
+ {combinationEdit.editingComboId === combination.comboId ? ( + { + const error = combinationEdit.validateComboName( + combinationEdit.editingCombinationName + ); + combinationEdit.setComboNameError(error); + if (!error) { + modals.openSaveModal(); + } + }} + disabled={ + !combinationEdit.isComboNameValid || + combinationEdit.editingCombinationName.trim().length === 0 + } + className="w-150" + /> + ) : ( + + )} + + {openMenuIndex === combination.comboId && ( + { + modals.openCombinationDeleteModal(combination.comboId); + setOpenMenuIndex(null); + }} + onRename={() => { + combinationEdit.startEditing(combination.comboId, combination.comboName); + setOpenMenuIndex(null); + }} + onDetail={hasDevices ? () => handleDetailView(combination.comboId) : undefined} + /> + )} +
+ )} + + {/* ์ƒ์„ธ๋ณด๊ธฐ ๋ชจ๋“œ */} + {isDetailView ? ( + + ) : ( + /* ์ผ๋ฐ˜ ๋ชจ๋“œ */ + <> + {hasDevices ? ( + { + const error = combinationEdit.validateComboName(name); + combinationEdit.setComboNameError(error); + }} + /> + ) : ( + { + const error = combinationEdit.validateComboName(name); + combinationEdit.setComboNameError(error); + }} + /> + )} + + )} +
+
+ ); + })} + + {/* ๋” ๋ณด๊ธฐ ๋ฒ„ํŠผ */} + {hasMore && ( +
+ +
+ )} +
+ ); +}; + +export default CombinationList; diff --git a/src/components/MyPage/CombinationMenu.tsx b/src/components/MyPage/CombinationMenu.tsx new file mode 100644 index 00000000..cc4c8234 --- /dev/null +++ b/src/components/MyPage/CombinationMenu.tsx @@ -0,0 +1,68 @@ +import { useState, memo } from 'react'; + +interface CombinationMenuProps { + hasDevices: boolean; + onDelete: () => void; + onRename: () => void; + onDetail?: () => void; +} + +const CombinationMenu = ({ hasDevices, onDelete, onRename, onDetail }: CombinationMenuProps) => { + const [hoveredMenuItem, setHoveredMenuItem] = useState(null); + + return ( +
+ {/* ์‚ญ์ œํ•˜๊ธฐ */} + + + {/* ์กฐํ•ฉ๋ช… ์ˆ˜์ •ํ•˜๊ธฐ */} + + + {/* ์ž์„ธํžˆ๋ณด๊ธฐ - ๊ธฐ๊ธฐ๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ ํ‘œ์‹œ */} + {hasDevices && onDetail && ( + + )} +
+ ); +}; + +export default memo(CombinationMenu); diff --git a/src/components/MyPage/DeleteCompleteModal.tsx b/src/components/MyPage/DeleteCompleteModal.tsx new file mode 100644 index 00000000..830fc596 --- /dev/null +++ b/src/components/MyPage/DeleteCompleteModal.tsx @@ -0,0 +1,32 @@ +import { memo } from 'react'; +import RemoveIcon from '@/assets/icons/remove.svg?react'; + +interface DeleteCompleteModalProps { + isFadingOut: boolean; +} + +const DeleteCompleteModal = ({ isFadingOut }: DeleteCompleteModalProps) => { + return ( + <> + {/* ๋ฐฐ๊ฒฝ ์˜ค๋ฒ„๋ ˆ์ด */} +
+ + {/* ํŒ์—… */} +
+
+ {/* ์•„์ด์ฝ˜ */} + + + {/* ํ…์ŠคํŠธ */} +

์‚ญ์ œ ์™„๋ฃŒ

+
+
+ + ); +}; + +export default memo(DeleteCompleteModal); diff --git a/src/components/MyPage/DeviceDeleteModal.tsx b/src/components/MyPage/DeviceDeleteModal.tsx new file mode 100644 index 00000000..90a6e5dc --- /dev/null +++ b/src/components/MyPage/DeviceDeleteModal.tsx @@ -0,0 +1,54 @@ +import { memo } from 'react'; +import RemoveIcon from '@/assets/icons/remove.svg?react'; + +interface DeviceDeleteModalProps { + isDeleting: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +const DeviceDeleteModal = ({ isDeleting, onConfirm, onCancel }: DeviceDeleteModalProps) => { + return ( + <> + {/* ๋ฐฐ๊ฒฝ ์˜ค๋ฒ„๋ ˆ์ด */} +
+ + {/* ๋ชจ๋‹ฌ */} +
+
e.stopPropagation()} + > + {/* ์•„์ด์ฝ˜ */} + + + {/* ํ…์ŠคํŠธ */} +

์„ ํƒํ•œ ๊ธฐ๊ธฐ๋“ค์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?

+ + {/* ๋ฒ„ํŠผ ๊ทธ๋ฃน */} +
+ + +
+
+
+ + ); +}; + +export default memo(DeviceDeleteModal); diff --git a/src/components/MyPage/EmptyCombinationCard.tsx b/src/components/MyPage/EmptyCombinationCard.tsx new file mode 100644 index 00000000..7790666e --- /dev/null +++ b/src/components/MyPage/EmptyCombinationCard.tsx @@ -0,0 +1,149 @@ +import { useState, memo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import StarIcon from '@/assets/icons/star.svg?react'; +import StarXIcon from '@/assets/icons/starx.svg?react'; +import StarHoverIcon from '@/assets/icons/starhover.svg?react'; +import PlusIcon from '@/assets/icons/plus.svg?react'; +import { formatDate } from '@/utils/format'; + +interface EmptyCombinationCardProps { + comboId: number; + comboName: string; + createdAt: string; + isPinned: boolean; + index: number; + isEditing: boolean; + editingName: string; + nameError: string | null; + onTogglePin: (e: React.MouseEvent, comboId: number) => void; + onNameChange: (e: React.ChangeEvent) => void; + onNameBlur: (name: string) => void; +} + +const EmptyCombinationCard = ({ + comboId, + comboName, + createdAt, + isPinned, + index, + isEditing, + editingName, + nameError, + onTogglePin, + onNameChange, + onNameBlur, +}: EmptyCombinationCardProps) => { + const navigate = useNavigate(); + const [hoveredStarComboId, setHoveredStarComboId] = useState(null); + + return ( +
+ {/* ์กฐํ•ฉ ์ •๋ณด (์ƒ์„ฑ์ผ ํฌํ•จ) */} +
+ {isEditing ? ( + /* ์ˆ˜์ • ๋ชจ๋“œ: ์ธํ’‹๋ฐ•์Šค + ๋ณ„ ์•„์ด์ฝ˜ + ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ */ +
+
+ e.stopPropagation()} + onBlur={() => onNameBlur(editingName)} + maxLength={20} + className={`h-52 px-12 rounded-button font-body-1-sm text-gray-300 focus:outline-none ${ + nameError ? 'border-2 border-warning' : 'border border-blue-600' + }`} + autoFocus + /> + {isPinned ? ( + onTogglePin(e, comboId)} + className={`!w-22 !h-22 -mt-2 cursor-pointer transition-opacity ${ + hoveredStarComboId === comboId ? 'opacity-80' : '' + }`} + onMouseEnter={() => setHoveredStarComboId(comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + <> + {hoveredStarComboId === comboId ? ( + onTogglePin(e, comboId)} + className="!w-22 !h-22 -mt-2 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + onTogglePin(e, comboId)} + className="!w-22 !h-22 -mt-2 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + )} + + )} +
+ {nameError &&

{nameError}

} +
+ ) : ( + /* ์ผ๋ฐ˜ ๋ชจ๋“œ: ์กฐํ•ฉ ๋ฒˆํ˜ธ + ์ƒ์„ฑ์ผ + ์กฐํ•ฉ๋ช… */ +
+
+

์กฐํ•ฉ{index + 1}

+

์ƒ์„ฑ์ผ: {formatDate(createdAt)}

+
+
+

{comboName}

+ {isPinned ? ( + onTogglePin(e, comboId)} + className={`!w-22 !h-22 -mt-3 cursor-pointer transition-opacity ${ + hoveredStarComboId === comboId ? 'opacity-80' : '' + }`} + onMouseEnter={() => setHoveredStarComboId(comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + <> + {hoveredStarComboId === comboId ? ( + onTogglePin(e, comboId)} + className="!w-22 !h-22 -mt-3 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + onTogglePin(e, comboId)} + className="!w-22 !h-22 -mt-3 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + )} + + )} +
+
+ )} +
+ + {/* ๋นˆ ์กฐํ•ฉ: ๊ธฐ๊ธฐ ์ถ”๊ฐ€ ๋ฒ„ํŠผ */} +
+ +
+
+ ); +}; + +export default memo(EmptyCombinationCard); diff --git a/src/components/MyPage/MyPageSidebar.tsx b/src/components/MyPage/MyPageSidebar.tsx new file mode 100644 index 00000000..a3dabcc4 --- /dev/null +++ b/src/components/MyPage/MyPageSidebar.tsx @@ -0,0 +1,88 @@ +import { type RefObject, memo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import RoundedLifestyleTag from '@/components/Lifestyle/RoundedLifestyleTag'; +import RecentlyViewedFloating from '@/components/RecentlyViewed/RecentlyViewedFloating'; +import SettingIcon from '@/assets/icons/setting.svg?react'; +import SupportIcon from '@/assets/icons/support.svg?react'; +import Logo from '@/assets/logos/logo.svg?react'; +import { formatDate } from '@/utils/format'; +import type { UserProfileResult } from '@/types/mypage/user'; + +interface MyPageSidebarProps { + userProfile: UserProfileResult | null; + isAuthLoading: boolean; + sidebarContentRef: RefObject; +} + +const MyPageSidebar = ({ userProfile, isAuthLoading, sidebarContentRef }: MyPageSidebarProps) => { + const navigate = useNavigate(); + + return ( + + ); +}; + +export default memo(MyPageSidebar); diff --git a/src/components/MyPage/SaveNameModal.tsx b/src/components/MyPage/SaveNameModal.tsx new file mode 100644 index 00000000..6cb63ff5 --- /dev/null +++ b/src/components/MyPage/SaveNameModal.tsx @@ -0,0 +1,53 @@ +import { memo } from 'react'; +import SaveIcon from '@/assets/icons/save.svg?react'; + +interface SaveNameModalProps { + isSaving: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +const SaveNameModal = ({ isSaving, onConfirm, onCancel }: SaveNameModalProps) => { + return ( + <> + {/* ๋ฐฐ๊ฒฝ ์˜ค๋ฒ„๋ ˆ์ด */} +
+ + {/* ๋ชจ๋‹ฌ */} +
+
e.stopPropagation()} + > + {/* ์•„์ด์ฝ˜ */} + + + {/* ํ…์ŠคํŠธ */} +

์กฐํ•ฉ๋ช…์„ ์ €์žฅํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?

+ + {/* ๋ฒ„ํŠผ ๊ทธ๋ฃน */} +
+ + +
+
+
+ + ); +}; + +export default memo(SaveNameModal); diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000..333dfa5a --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,58 @@ +import { memo } from 'react'; +import type { Product } from '@/types/product'; + +interface ProductCardProps { + product: Product; + onClick?: () => void; +} + +const ProductCard: React.FC = memo(({ product, onClick }) => { + return ( +
+ {/* Image - ์ •์‚ฌ๊ฐํ˜• */} +
+ {product.image ? ( + {product.name} + ) : null} +
+ + {/* Content */} +
+ {/* Name & Category */} +
+

+ {product.name.length > 19 ? `${product.name.slice(0, 19)}...` : product.name} +

+

{product.category}

+
+ + {/* Price */} +

+ โ‚ฉ {(product.price ?? 0).toLocaleString()} +

+ + {/* Color Chips */} + {/*
+ {product.colors.map((color, idx) => ( +
+ ))} +
*/} +
+
+ ); +}); + +ProductCard.displayName = 'ProductCard'; + +export default ProductCard; diff --git a/src/components/RecentlyViewed/RecentlyViewedCard.tsx b/src/components/RecentlyViewed/RecentlyViewedCard.tsx new file mode 100644 index 00000000..b2ab22c5 --- /dev/null +++ b/src/components/RecentlyViewed/RecentlyViewedCard.tsx @@ -0,0 +1,44 @@ +import clsx from 'clsx'; +import type { RecentlyViewedDevice } from '@/types/recentlyViewed/recentlyViewed'; + +interface RecentlyViewedCardProps { + device: RecentlyViewedDevice; + onClick?: () => void; + className?: string; +} + +const RecentlyViewedCard = ({ device, onClick, className }: RecentlyViewedCardProps) => { + return ( +
+ {/* ์ด๋ฏธ์ง€ - 149x149 */} +
+ {device.imageUrl && ( + {device.name} + )} +
+ + {/* ์ฝ˜ํ…์ธ  */} +
+ {/* ์ œํ’ˆ๋ช… - 16px, Regular, line-height 22px */} +

{device.name}

+ + {/* ์นดํ…Œ๊ณ ๋ฆฌ - 12px, SemiBold, Gray300 */} +

{device.deviceType}

+ + {/* ๊ฐ€๊ฒฉ - 16px, SemiBold */} +

+ {device.priceKrw != null ? `โ‚ฉ ${device.priceKrw.toLocaleString()}` : '-'} +

+
+
+ ); +}; + +export default RecentlyViewedCard; diff --git a/src/components/RecentlyViewed/RecentlyViewedFloating.tsx b/src/components/RecentlyViewed/RecentlyViewedFloating.tsx new file mode 100644 index 00000000..652653d2 --- /dev/null +++ b/src/components/RecentlyViewed/RecentlyViewedFloating.tsx @@ -0,0 +1,103 @@ +import { useRef, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useGetRecentlyViewed } from '@/apis/recentlyViewed/getRecentlyViewed'; +import RecentlyViewedCard from './RecentlyViewedCard'; + +interface RecentlyViewedFloatingProps { + userName: string; + sidebarContentRef: React.RefObject; +} + +const RecentlyViewedFloating = ({ + userName, + sidebarContentRef, +}: RecentlyViewedFloatingProps) => { + const navigate = useNavigate(); + const { data: devices = [], isLoading } = useGetRecentlyViewed(); + const containerRef = useRef(null); + const [isFloating, setIsFloating] = useState(false); + const [leftPosition, setLeftPosition] = useState(0); + + // ํ‘œ์‹œํ•  ๊ธฐ๊ธฐ (์ตœ๋Œ€ 2๊ฐœ) + const displayDevices = devices.slice(0, 2); + + useEffect(() => { + const updatePosition = () => { + if (!sidebarContentRef.current) return; + + const sidebarRect = sidebarContentRef.current.getBoundingClientRect(); + const GNB_HEIGHT = 80; + const MARGIN_TOP = 60; + + // ์‚ฌ์ด๋“œ๋ฐ” ์ฝ˜ํ…์ธ ์˜ ํ•˜๋‹จ์ด GNB + ๋งˆ์ง„ ์œ„๋กœ ์˜ฌ๋ผ๊ฐ”์„ ๋•Œ ํ”Œ๋กœํŒ… ์‹œ์ž‘ + const threshold = GNB_HEIGHT + MARGIN_TOP + 60; + + if (sidebarRect.bottom < threshold) { + setIsFloating(true); + setLeftPosition(sidebarRect.left); + } else { + setIsFloating(false); + } + }; + + const handleScroll = () => { + requestAnimationFrame(updatePosition); + }; + + const handleResize = () => { + updatePosition(); + }; + + window.addEventListener('scroll', handleScroll); + window.addEventListener('resize', handleResize); + updatePosition(); + + return () => { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleResize); + }; + }, [sidebarContentRef]); + + // ๋กœ๋”ฉ ์ค‘์ด๊ฑฐ๋‚˜ ๊ธฐ๊ธฐ๊ฐ€ ์—†์œผ๋ฉด ๋ Œ๋”๋งํ•˜์ง€ ์•Š์Œ + if (isLoading || displayDevices.length === 0) return null; + + const handleCardClick = (deviceId: number) => { + navigate(`/devices?productId=${deviceId}`); + }; + + return ( +
+ {/* ํ—ค๋” */} +
+ {userName} + ๋‹˜์ด ์ตœ๊ทผ์— ๋ณธ +
+ + {/* ์นด๋“œ ๋ชฉ๋ก - ์„ธ๋กœ ๋ฐฐ์น˜, ์ตœ๋Œ€ 2๊ฐœ */} +
+ {displayDevices.map((device) => ( + handleCardClick(device.deviceId)} + /> + ))} +
+
+ ); +}; + +export default RecentlyViewedFloating; diff --git a/src/components/Setting/EmailSection.tsx b/src/components/Setting/EmailSection.tsx new file mode 100644 index 00000000..046b40d3 --- /dev/null +++ b/src/components/Setting/EmailSection.tsx @@ -0,0 +1,52 @@ +type EmailSectionProps = { + value: string; +}; + +const EmailSection = ({ value }: EmailSectionProps) => { + return ( +
+
+

์ด๋ฉ”์ผ

+
+ +
+
+
+ ); +}; + +export default EmailSection; diff --git a/src/components/Setting/LifestyleSelectSection.tsx b/src/components/Setting/LifestyleSelectSection.tsx new file mode 100644 index 00000000..23b8f1ab --- /dev/null +++ b/src/components/Setting/LifestyleSelectSection.tsx @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import RoundedLifestyleTag from '@/components/Lifestyle/RoundedLifestyleTag'; +import { LIFESTYLE_DISPLAY_TAGS, type LifestyleDisplayTag } from '@/constants/lifestyle'; +type LifestyleSelectSectionProps = { + value: LifestyleDisplayTag[]; + onChange: (next: LifestyleDisplayTag[]) => void; +}; + +const LifestyleSelectSection = ({ value, onChange }: LifestyleSelectSectionProps) => { + const handleToggle = useCallback( + (label: string, nextSelected: boolean) => { + const tag = label as LifestyleDisplayTag; + if (nextSelected) { + onChange([tag]); + return; + } + onChange([]); + }, + [onChange] + ); + + return ( +
+
+

๋ผ์ดํ”„์Šคํƒ€์ผ

+
+ {LIFESTYLE_DISPLAY_TAGS.map((label) => ( + + ))} +
+
+
+ ); +}; + +export default LifestyleSelectSection; diff --git a/src/components/Setting/NewPasswordInputSection.tsx b/src/components/Setting/NewPasswordInputSection.tsx new file mode 100644 index 00000000..4de5021e --- /dev/null +++ b/src/components/Setting/NewPasswordInputSection.tsx @@ -0,0 +1,24 @@ +import PasswordInputSection from '@/components/Setting/PasswordInputSection'; + +type NewPasswordInputSectionProps = { + value: string; + onChange: (next: string) => void; + errorMessage?: string; +}; + +const NewPasswordInputSection = ({ + value, + onChange, + errorMessage, +}: NewPasswordInputSectionProps) => { + return ( + + ); +}; + +export default NewPasswordInputSection; diff --git a/src/components/Setting/NicknameEditSection.tsx b/src/components/Setting/NicknameEditSection.tsx new file mode 100644 index 00000000..b69e3f50 --- /dev/null +++ b/src/components/Setting/NicknameEditSection.tsx @@ -0,0 +1,74 @@ +import Cancel from '@/assets/icons/cancel.svg?react'; + +type NicknameEditSectionProps = { + value: string; + onChange: (next: string) => void; + errorMessage?: string; +}; + +const NicknameEditSection = ({ value, onChange, errorMessage }: NicknameEditSectionProps) => { + return ( +
+
+

๋‹‰๋„ค์ž„

+
+ onChange(e.target.value)} + className=" + w-full h-full + bg-transparent + outline-none + pr-40 + text-black + font-body-1-r + " + /> + +
+ {errorMessage &&

{errorMessage}

} +
+
+ ); +}; + +export default NicknameEditSection; diff --git a/src/components/Setting/OldPasswordInputSection.tsx b/src/components/Setting/OldPasswordInputSection.tsx new file mode 100644 index 00000000..961b66aa --- /dev/null +++ b/src/components/Setting/OldPasswordInputSection.tsx @@ -0,0 +1,24 @@ +import PasswordInputSection from '@/components/Setting/PasswordInputSection'; + +type OldPasswordInputSectionProps = { + value: string; + onChange: (next: string) => void; + errorMessage?: string; +}; + +const OldPasswordInputSection = ({ + value, + onChange, + errorMessage, +}: OldPasswordInputSectionProps) => { + return ( + + ); +}; + +export default OldPasswordInputSection; diff --git a/src/components/Setting/PasswordConfirmInputSection.tsx b/src/components/Setting/PasswordConfirmInputSection.tsx new file mode 100644 index 00000000..13acab30 --- /dev/null +++ b/src/components/Setting/PasswordConfirmInputSection.tsx @@ -0,0 +1,24 @@ +import PasswordInputSection from '@/components/Setting/PasswordInputSection'; + +type PasswordConfirmInputSectionProps = { + value: string; + onChange: (next: string) => void; + errorMessage?: string; +}; + +const PasswordConfirmInputSection = ({ + value, + onChange, + errorMessage, +}: PasswordConfirmInputSectionProps) => { + return ( + + ); +}; + +export default PasswordConfirmInputSection; diff --git a/src/components/Setting/PasswordInputSection.tsx b/src/components/Setting/PasswordInputSection.tsx new file mode 100644 index 00000000..3017272b --- /dev/null +++ b/src/components/Setting/PasswordInputSection.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react'; +import Eye from '@/assets/icons/eye.svg?react'; +import ClosedEye from '@/assets/icons/closedeye.svg?react'; + +type PasswordInputSectionProps = { + label: string; + value: string; + onChange: (next: string) => void; + errorMessage?: string; +}; + +const PasswordInputSection = ({ + label, + value, + onChange, + errorMessage, +}: PasswordInputSectionProps) => { + const [isVisible, setIsVisible] = useState(false); + + const handleChange = (next: string) => { + onChange(next.slice(0, 20)); + }; + + return ( +
+
+

{label}

+
+ handleChange(e.target.value)} + className={` + w-full h-full + bg-transparent + outline-none + pr-40 + font-body-1-r + ${ + isVisible + ? 'text-black caret-black' + : ` + text-transparent caret-transparent select-none + selection:bg-transparent selection:text-transparent + ` + } + `} + /> + {!isVisible && value.length > 0 && ( + + {'*'.repeat(value.length)} + + )} + +
+ {errorMessage &&

{errorMessage}

} +
+
+ ); +}; + +export default PasswordInputSection; diff --git a/src/components/Setting/PasswordSettingSection.tsx b/src/components/Setting/PasswordSettingSection.tsx new file mode 100644 index 00000000..51300ed1 --- /dev/null +++ b/src/components/Setting/PasswordSettingSection.tsx @@ -0,0 +1,44 @@ +import { useNavigate } from 'react-router-dom'; + +const PasswordSettingSection = () => { + const navigate = useNavigate(); + const handleClick = () => { + navigate('/my/settings/password'); + }; + + return ( +
+
+

๋น„๋ฐ€๋ฒˆํ˜ธ

+ +
+
+ ); +}; + +export default PasswordSettingSection; diff --git a/src/constants/auth.ts b/src/constants/auth.ts new file mode 100644 index 00000000..ae3e45e5 --- /dev/null +++ b/src/constants/auth.ts @@ -0,0 +1,3 @@ +export const OAUTH = { + google: `${import.meta.env.VITE_SERVER_API_URL}/oauth2/authorization/google`, +} as const; diff --git a/src/constants/combination.ts b/src/constants/combination.ts new file mode 100644 index 00000000..3dda4978 --- /dev/null +++ b/src/constants/combination.ts @@ -0,0 +1,52 @@ +export const COMBINATION_NAMES = ['์—ฐ๋™์„ฑ', 'ํŽธ์˜์„ฑ', '๋ผ์ดํ”„์Šคํƒ€์ผ'] as const; +export const COMBINATION_STATUSES = ['์ตœ์ ', '์–‘ํ˜ธ', '๋ณดํ†ต', '๋ฏธํก', '-'] as const; + +export type CombinationName = (typeof COMBINATION_NAMES)[number]; +export type CombinationStatus = (typeof COMBINATION_STATUSES)[number]; + +export const COMBINATION_NAME_STYLE_MAP: Record = { + ์—ฐ๋™์„ฑ: 'bg-blue-200 text-blue-800', + ํŽธ์˜์„ฑ: 'bg-light-green text-dark-green', + ๋ผ์ดํ”„์Šคํƒ€์ผ: 'bg-light-yellow text-dark-yellow', +}; + +export const COMBINATION_STATUS_STYLE_MAP: Record = { + ์ตœ์ : 'font-caption-sm text-optimal', + ์–‘ํ˜ธ: 'font-caption-sm text-good', + ๋ณดํ†ต: 'font-caption-sm text-normal', + ๋ฏธํก: 'font-caption-sm text-poor', + '-': 'font-caption-sm text-optimal', +}; + +export const COMBO_MOTION = { + HEADER_H: 80, + + INNER_W: 600, + INNER_H: 72, + + SHRINK_W: 558, + SHRINK_H: 66, + + OUTER_W: 638, + OUTER_H: 111, + + T_SHRINK: 420, + T_STACK: 520, + + DROP_DURATION: 1500, + DROP_EASING: 'cubic-bezier(0.12, 0.95, 0.18, 1)', + + LIFT_DISTANCE: 90, + LIFT_DURATION: 1500, + LIFT_DELAY: 180, + LIFT_EASING: 'cubic-bezier(0.12, 0.9, 0.18, 1)', + + DOUBLE_DELAY: 160, + EXTRAS_AT_LIFT_PROGRESS: 0.01, +} as const; + +export const MYPAGE_SORT_OPTIONS = [ + { value: 'latest', label: '์ตœ๊ทผ์ƒ์„ฑ์ˆœ' }, + { value: 'oldest', label: '์˜ค๋ž˜๋œ์ˆœ' }, + { value: 'alphabetical', label: '๊ฐ€๋‚˜๋‹ค์ˆœ' }, +]; diff --git a/src/constants/deviceMapping.ts b/src/constants/deviceMapping.ts new file mode 100644 index 00000000..b3b03b35 --- /dev/null +++ b/src/constants/deviceMapping.ts @@ -0,0 +1,26 @@ +// ์นดํ…Œ๊ณ ๋ฆฌ ID๋ฅผ API deviceType์œผ๋กœ ๋ณ€ํ™˜ +export const getCategoryDeviceType = (categoryId: number | null): string | undefined => { + if (!categoryId) return undefined; + const mapping: Record = { + 1: 'SMARTPHONE', + 2: 'LAPTOP', + 3: 'TABLET', + 4: 'SMARTWATCH', + 5: 'AUDIO', + 6: 'KEYBOARD', + 7: 'MOUSE', + 8: 'CHARGER', + }; + return mapping[categoryId]; +}; + +// sortOption์„ API sortType์œผ๋กœ ๋ณ€ํ™˜ +export const getSortType = (sortOption: string) => { + const mapping: Record = { + 'latest': 'LATEST', + 'alphabetical': 'NAME_ASC', + 'price-low': 'PRICE_ASC', + 'price-high': 'PRICE_DESC', + }; + return mapping[sortOption] ?? 'LATEST'; +}; diff --git a/src/constants/devices.ts b/src/constants/devices.ts new file mode 100644 index 00000000..38e3b9cc --- /dev/null +++ b/src/constants/devices.ts @@ -0,0 +1,57 @@ +import PhoneIcon from '@/assets/icons/phone.svg?react'; +import LaptopIcon from '@/assets/icons/laptop.svg?react'; +import TabletIcon from '@/assets/icons/tablet.svg?react'; +import WatchIcon from '@/assets/icons/watch.svg?react'; +import HeadsetIcon from '@/assets/icons/headset.svg?react'; +import KeyboardIcon from '@/assets/icons/keyboard.svg?react'; +import MouseIcon from '@/assets/icons/mouse.svg?react'; +import ChargeIcon from '@/assets/icons/charge.svg?react'; +import type { ComponentType, SVGProps } from 'react'; + +export interface DeviceCategory { + id: number; + name: string; + Icon: ComponentType>; +} + +export interface FilterOption { + value: string; + label: string; +} + +export const DEVICE_CATEGORIES: DeviceCategory[] = [ + { id: 1, name: '์Šค๋งˆํŠธํฐ', Icon: PhoneIcon }, + { id: 2, name: '๋…ธํŠธ๋ถ', Icon: LaptopIcon }, + { id: 3, name: 'ํƒœ๋ธ”๋ฆฟ', Icon: TabletIcon }, + { id: 4, name: '์Šค๋งˆํŠธ์›Œ์น˜', Icon: WatchIcon }, + { id: 5, name: '์ด์–ดํฐ/ํ—ค๋“œํฐ', Icon: HeadsetIcon }, + { id: 6, name: 'ํ‚ค๋ณด๋“œ', Icon: KeyboardIcon }, + { id: 7, name: '๋งˆ์šฐ์Šค', Icon: MouseIcon }, + { id: 8, name: '์ถฉ์ „๊ธฐ', Icon: ChargeIcon }, +]; + +export const SORT_OPTIONS: FilterOption[] = [ + { value: 'latest', label: '์ตœ์‹ ์ˆœ' }, + { value: 'alphabetical', label: '๊ฐ€๋‚˜๋‹ค์ˆœ' }, + { value: 'price-low', label: '๋‚ฎ์€๊ฐ€๊ฒฉ์ˆœ' }, + { value: 'price-high', label: '๋†’์€๊ฐ€๊ฒฉ์ˆœ' }, +]; + +export const PRICE_OPTIONS: FilterOption[] = [ + { value: 'under-100', label: '100๋งŒ์› ์ดํ•˜' }, + { value: '100-150', label: '100~150๋งŒ์›' }, + { value: '150-200', label: '150~200๋งŒ์›' }, + { value: 'over-200', label: '200๋งŒ์› ์ด์ƒ' }, +]; + +export const BRAND_OPTIONS: FilterOption[] = [ + { value: 'apple', label: 'Apple' }, + { value: 'samsung', label: 'Samsung' }, + { value: 'sony', label: 'Sony' }, + { value: 'logitech', label: 'Logitech' }, +]; + +export const SCROLL_CONSTANTS = { + BOTTOM_BUFFER: 50, + TOP_BUTTON_THRESHOLD: 1800, +} as const; diff --git a/src/constants/evaluation/connectivity.ts b/src/constants/evaluation/connectivity.ts new file mode 100644 index 00000000..a549d451 --- /dev/null +++ b/src/constants/evaluation/connectivity.ts @@ -0,0 +1,32 @@ +import { GRADE } from './grade'; +import type { Grade } from './grade'; + +// ์—ฐ๋™์„ฑ ์นด๋“œ ๋ฐ์ดํ„ฐ ํƒ€์ž… +export type EvaluationCardData = { + tags: string[]; + text: string; +}; + +// ์—ฐ๋™์„ฑ ๋งคํ•‘ ํ…Œ์ด๋ธ” +export const CONNECTIVITY: Record = { + [GRADE.BEST]: { + tags: ['OSํ†ตํ•ฉ', '์™„๋ฒฝ ํ˜ธํ™˜์„ฑ'], + text: '๋ชจ๋“  ๊ธฐ๊ธฐ๊ฐ€ ํ•˜๋‚˜์˜ OS ์ƒํƒœ๊ณ„๋กœ ์™„๋ฒฝํ•˜๊ฒŒ ํ†ตํ•ฉ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ํ˜ธํ™˜์„ฑ ๊ธฐ์ค€์ด ๋‹ค์ˆ˜ ์ถฉ์กฑ๋œ ์ตœ์ƒ์˜ ์ƒํƒœ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.GOOD]: { + tags: ['์•ˆ์ •์  ์—ฐ๊ฒฐ', 'ํ’ˆ์งˆ ์šฐ์ˆ˜'], + text: '์ฃผ์š” ๊ธฐ๊ธฐ ๊ฐ„์˜ ์—ฐ๊ฒฐ์ด ์•ˆ์ •์ ์ž…๋‹ˆ๋‹ค. ๋‹ค๋งŒ ์กฐํ•ฉ์— ๋”ฐ๋ผ ๋งˆ์šฐ์Šค ์ œ์Šค์ฒ˜๋‚˜ ์˜ค๋””์˜ค ์ฝ”๋ฑ ๋“ฑ ์„ธ๋ถ€์ ์ธ ๋ถ€๊ฐ€ ๊ธฐ๋Šฅ ์ค‘ ์ผ๋ถ€๊ฐ€ ์ œํ•œ์ ์œผ๋กœ ์ž‘๋™ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }, + [GRADE.NORMAL]: { + tags: ['OS ํ˜ผํ•ฉ'], + text: '๊ธฐ๋ณธ์ ์ธ ์—ฐ๊ฒฐ์€ ๊ฐ€๋Šฅํ•˜๋‚˜ ์„œ๋กœ ๋‹ค๋ฅธ OS๊ฐ€ ์„ž์—ฌ ์žˆ์Šต๋‹ˆ๋‹ค. ํ‚ค๋ณด๋“œ ๋ ˆ์ด์•„์›ƒ ๋ถˆ์ผ์น˜๋‚˜ ๋‹จ์ถ•ํ‚ค ํ™œ์šฉ์— ์ œ์•ฝ์ด ์˜ˆ์ƒ๋˜์–ด, ๊ธฐ๊ธฐ ๊ฐ„์˜ ์‹œ๋„ˆ์ง€๋ฅผ ํ™•์ธํ•ด ๋ณผ ํ•„์š”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.', + }, + [GRADE.POOR]: { + tags: ['OS ๊ณ ๋ฆฝ', '์—ฐ๋™์„ฑ ๋ถ€์กฑ'], + text: '์Šค๋งˆํŠธ์›Œ์น˜์™€ ์Šค๋งˆํŠธํฐ์˜ OS๊ฐ€ ๋‹ฌ๋ผ ํ•ต์‹ฌ ๊ธฐ๋Šฅ์„ ์“ธ ์ˆ˜ ์—†๊ฑฐ๋‚˜, ์—ฐ๊ฒฐ ๋Œ€์ƒ์ด ์—†์–ด ํ™œ์šฉ๋„๊ฐ€ ๋–จ์–ด์ง€๋Š” ๊ธฐ๊ธฐ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฐ๋™์„ฑ์ด ๋งค์šฐ ๋‚ฎ์€ ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.UNKNOWN]: { + tags: ['-'], + text: '-', + }, +}; diff --git a/src/constants/evaluation/convenience.ts b/src/constants/evaluation/convenience.ts new file mode 100644 index 00000000..0e101438 --- /dev/null +++ b/src/constants/evaluation/convenience.ts @@ -0,0 +1,27 @@ +import { GRADE } from './grade'; +import type { Grade } from './grade'; +import type { EvaluationCardData } from './connectivity'; + +// ํŽธ์˜์„ฑ ๋งคํ•‘ ํ…Œ์ด๋ธ” +export const CONVENIENCE: Record = { + [GRADE.BEST]: { + tags: ['USB-C', '๋ฐฐํ„ฐ๋ฆฌ ํšจ์œจ ์šฐ์ˆ˜'], + text: '๋ชจ๋“  ๊ธฐ๊ธฐ๊ฐ€ USB-C๋กœ ํ†ต์ผ๋˜์—ˆ์œผ๋ฉฐ ๋ฐฐํ„ฐ๋ฆฌ ํšจ์œจ ๋˜ํ•œ ์šฐ์ˆ˜์ž…๋‹ˆ๋‹ค. ๋‹จ์ผ ์ถฉ์ „๊ธฐ๋กœ ๋…ธํŠธ๋ถ๊นŒ์ง€ ์™„๋ฒฝํ•˜๊ฒŒ ์ปค๋ฒ„ ๊ฐ€๋Šฅํ•œ ์ด์ƒ์ ์ธ ํ™˜๊ฒฝ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.GOOD]: { + tags: ['์•ˆ์ •์  ์ถฉ์ „', '๋ฉ€ํ‹ฐํƒœ์Šคํ‚น', '๋ฐฐํ„ฐ๋ฆฌ ์ค€์ˆ˜'], + text: '์ „๋ฐ˜์ ์ธ ๋ฐฐํ„ฐ๋ฆฌ ํšจ์œจ๊ณผ ์ถฉ์ „ ์†๋„๊ฐ€ ์•ˆ์ •์ ์ž…๋‹ˆ๋‹ค. ๋ฌด์„  ์ถฉ์ „์ด๋‚˜ ๊ณ ์ถœ๋ ฅ PD ์ถฉ์ „ ๋“ฑ ํ•ต์‹ฌ ํŽธ์˜ ๊ธฐ๋Šฅ ์ค‘ ์ผ๋ถ€๊ฐ€ ํฌํ•จ๋˜์–ด ๊ด€๋ฆฌ๊ฐ€ ์ˆ˜์›”ํ•ฉ๋‹ˆ๋‹ค.', + }, + [GRADE.NORMAL]: { + tags: ['๋‹จ์ž ํ˜ผ์žฌ', '์ถฉ์ „ ์†๋„ ํ™•์ธ', 'ํฌํŠธ ๋ฐฐ๋ถ„'], + text: '์‚ฌ์šฉ์— ํฐ ์ง€์žฅ์€ ์—†์œผ๋‚˜, ๊ธฐ๊ธฐ์— ๋”ฐ๋ผ ์ถฉ์ „ ๋‹จ์ž๊ฐ€ ๋‹ค๋ฅด๊ฑฐ๋‚˜ ์ถฉ์ „๊ธฐ ์ถœ๋ ฅ์ด ๋‹ค์†Œ ๋‚ฎ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋™์‹œ ์ถฉ์ „ ์‹œ ํฌํŠธ ๋ฐฐ๋ถ„์„ ๊ณ ๋ คํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.', + }, + [GRADE.POOR]: { + tags: ['์–ด๋Œ‘ํ„ฐ ํ•„์š”', '๋‹จ์ž ๋ถˆ์ผ์น˜', '์ถฉ์ „ ๋ณ‘๋ชฉ'], + text: '๋…ธํŠธ๋ถ ์ถฉ์ „์„ ์œ„ํ•ด ์ „์šฉ ์–ด๋Œ‘ํ„ฐ๊ฐ€ ํ•„์š”ํ•˜๊ฑฐ๋‚˜ ์ถฉ์ „ ํฌํŠธ๊ฐ€ ๊ธฐ๊ธฐ ์ˆ˜์— ๋น„ํ•ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค. ๋‹จ์ž ํ˜ผ์žฌ๋กœ ์ธํ•ด ์ผ€์ด๋ธ” ๊ด€๋ฆฌ๊ฐ€ ๋ฒˆ๊ฑฐ๋กœ์šด ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.UNKNOWN]: { + tags: ['-'], + text: '-', + }, +}; diff --git a/src/constants/evaluation/grade.ts b/src/constants/evaluation/grade.ts new file mode 100644 index 00000000..e29e4164 --- /dev/null +++ b/src/constants/evaluation/grade.ts @@ -0,0 +1,17 @@ +// ๋“ฑ๊ธ‰ ์ƒ์ˆ˜ ๋ฐ ํƒ€์ž… ์ •์˜ + +export const GRADE = { + BEST: '์ตœ์ ', + GOOD: '์–‘ํ˜ธ', + NORMAL: '๋ณดํ†ต', + POOR: '๋ฏธํก', + UNKNOWN: '-', +} as const; + +export type Grade = (typeof GRADE)[keyof typeof GRADE]; + +// API ์‘๋‹ต ๋“ฑ๊ธ‰ ๋ฌธ์ž์—ด โ†’ Grade ํƒ€์ž… ๋ณ€ํ™˜ +export const toGrade = (raw: string): Grade => { + const valid: string[] = [GRADE.BEST, GRADE.GOOD, GRADE.NORMAL, GRADE.POOR]; + return valid.includes(raw) ? (raw as Grade) : GRADE.UNKNOWN; +}; diff --git a/src/constants/evaluation/lifestyle.ts b/src/constants/evaluation/lifestyle.ts new file mode 100644 index 00000000..f635104f --- /dev/null +++ b/src/constants/evaluation/lifestyle.ts @@ -0,0 +1,153 @@ +import { GRADE } from './grade'; +import type { Grade } from './grade'; +import type { EvaluationCardData } from './connectivity'; + +// ๋ผ์ดํ”„์Šคํƒ€์ผ ํƒœ๊ทธ ํ‚ค (์œ ์ € ํ”„๋กœํ•„ ๊ธฐ์ค€) +export type LifestyleKey = + | 'Office' + | 'Developer' + | 'Game' + | 'Study' + | 'Video-editing' + | 'Tour/portability'; + +// ๋ผ์ดํ”„์Šคํƒ€์ผ ๋งคํ•‘ ํ…Œ์ด๋ธ” (ํƒœ๊ทธ๋ณ„ ร— ๋“ฑ๊ธ‰๋ณ„) +export const LIFESTYLE: Record> = { + Office: { + [GRADE.BEST]: { + tags: ['ํ’€๋ฐฐ์—ด ์ถ”์ฒœ', 'HDMI ์™„๋น„'], + text: '์‚ฌ๋ฌด ์ƒ์‚ฐ์„ฑ์— ํ•„์š”ํ•œ ํ•ต์‹ฌ ๊ธฐ๊ธฐ๋“ค์ด ๋ชจ๋‘ ๊ฐ–์ถฐ์ง„ ์ƒํƒœ์ž…๋‹ˆ๋‹ค. ์งง์€ ์—…๋ฌด์™€ ์™ธ๋ถ€ ์ถœ๋ ฅ ํ™˜๊ฒฝ ๋ชจ๋‘ ์™„๋ฒฝํ•˜๊ฒŒ ๋Œ€์‘ํ•  ์ˆ˜ ์žˆ๋Š” ์ตœ์ ์˜ ๊ตฌ์„ฑ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.GOOD]: { + tags: ['ํ‘œ์ค€ ์‚ฌ๋ฌด ์‚ฌ์–‘'], + text: '์›ํ™œํ•œ ์—…๋ฌด๊ฐ€ ๊ฐ€๋Šฅํ•œ ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค. ์กฐํ•ฉ์— ํฌํ•จ๋œ ๊ธฐ๊ธฐ๋“ค์ด ์‚ฌ๋ฌด์šฉ์œผ๋กœ ์ ํ•ฉํ•œ ์‚ฌ์–‘์„ ๊ฐ–์ถ”๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.', + }, + [GRADE.NORMAL]: { + tags: ['๊ธฐ๋ณธ ๊ตฌ์„ฑ', '๋ณด์™„ ์ถ”์ฒœ'], + text: '๊ธฐ๋ณธ์ ์ธ ์—…๋ฌด๋Š” ๊ฐ€๋Šฅํ•˜์ง€๋งŒ ํ•ต์‹ฌ ๊ธฐ๊ธฐ(ํ‚ค๋ณด๋“œ/๋…ธํŠธ๋ถ ๋“ฑ)๊ฐ€ ๋น ์ ธ ์žˆ๊ฑฐ๋‚˜, ๊ธฐ๊ธฐ๋“ค์˜ ํฌํŠธ ์‚ฌ์–‘์ด ๋‹ค์†Œ ์•„์‰ฌ์šด ๊ตฌ๊ฐ„์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.POOR]: { + tags: ['์‚ฌ๋ฌด์šฉ ๋ถ€์ ํ•ฉ', 'HDMI ์–ด๋Œ‘ํ„ฐ ํ•„์ˆ˜'], + text: '์‚ฌ๋ฌด ํšจ์œจ์„ ์ €ํ•ดํ•˜๋Š” ์š”์†Œ๊ฐ€ ๋ฐœ๊ฒฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋ฏธ๋‹ˆ ๋ฐฐ์—ด ํ‚ค๋ณด๋“œ๋กœ ์ธํ•œ ์ž…๋ ฅ ๋ถˆํŽธ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๋…ธํŠธ๋ถ์˜ ๋‹จ์ž ๋ถ€์กฑ์œผ๋กœ ์ถ”๊ฐ€ ์–ด๋Œ‘ํ„ฐ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.', + }, + [GRADE.UNKNOWN]: { + tags: ['-'], + text: '-', + }, + }, + + Developer: { + [GRADE.BEST]: { + tags: ['๊ฐœ๋ฐœํ™˜๊ฒฝ์šฐ์ˆ˜', 'Unix๊ธฐ๋ฐ˜ OS', 'ํฌํŠธ ํ™•์žฅ์„ฑ'], + text: '๊ฐœ๋ฐœ ํ™˜๊ฒฝ ๊ตฌ์ถ•์— ์ตœ์ ํ™”๋œ OS์™€ ๋„‰๋„‰ํ•œ ํฌํŠธ ๊ตฌ์„ฑ์„ ๊ฐ–์ท„์Šต๋‹ˆ๋‹ค. ์ธ์ฒด๊ณตํ•™ ๋งˆ์šฐ์Šค ์„ค๊ณ„๋ฅผ ํ†ตํ•ด ์žฅ์‹œ๊ฐ„ ์ฝ”๋”ฉ์—๋„ ์†๋ชฉ ์•ˆ์ •์„ ์œ ์ง€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }, + [GRADE.GOOD]: { + tags: ['ํ—ˆ๋ธŒ์ฒดํฌ', '์ฝ”๋”ฉ์ ํ•ฉ'], + text: '์ „๋ฐ˜์ ์œผ๋กœ ๊ฐœ๋ฐœ์— ์ ํ•ฉํ•œ ๊ตฌ์„ฑ์ž…๋‹ˆ๋‹ค. Unix ๊ธฐ๋ฐ˜ OS๋กœ ํ™˜๊ฒฝ ๊ตฌ์ถ•์ด ์ˆ˜์›”ํ•˜๋ฉฐ, ์ผ๋ถ€ ํฌํŠธ ๊ตฌ์„ฑ์ด๋‚˜ ๋งˆ์šฐ์Šค ์ข…๋ฅ˜์— ๋”ฐ๋ผ ๊ธฐ๊ธฐ ๋ณด์™„์ด ๊ณ ๋ ค๋˜๋Š” ๊ตฌ๊ฐ„์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.NORMAL]: { + tags: ['OS ํ†ต์ผ ๊ฐ€๋Šฅ', 'ํฌํŠธ ํ™•์žฅ์„ฑ ์œ ์˜'], + text: '๊ธฐ๋ณธ์ ์ธ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์„ ๊ฐ–์ถ”๊ณ  ์žˆ์œผ๋‚˜, ํฌํŠธ ํ™•์žฅ์„ฑ์ด ์•„์‰ฌ์šฐ๋ฉฐ, ๊ธฐ๊ธฐ๋“ค์˜ OS๋ฅผ ๋” ํ†ตํ•ฉํ•  ์—ฌ์ง€๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.', + }, + [GRADE.POOR]: { + tags: ['OS ๋ถˆ์ผ์น˜', 'ํฌํŠธ ํ™•์žฅ์„ฑ ํ•„์ˆ˜'], + text: '๋ฉ”์ธ ๊ธฐ๊ธฐ๋“ค์˜ ์šด์˜์ฒด์ œ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์•„ ์ž‘์—…์— ๋ถˆํŽธํ•จ์„ ๋А๋‚„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }, + [GRADE.UNKNOWN]: { + tags: ['-'], + text: '-', + }, + }, + + Game: { + [GRADE.BEST]: { + tags: ['GPU', '๊ณ ์‚ฌ์–‘'], + text: '๊ณ ์‚ฌ์–‘ ๊ฒŒ์ž„์„ ์ฆ๊ธฐ๊ธฐ์— ์ถฉ๋ถ„ํ•œ ์™ธ์žฅ ๊ทธ๋ž˜ํ”ฝ๊ณผ ์ง€์—ฐ ์—†๋Š” ๋ฌด์„  ์—ฐ๊ฒฐ ํ™˜๊ฒฝ์„ ๊ตฌ์ถ•ํ–ˆ์Šต๋‹ˆ๋‹ค. ํผํฌ๋จผ์Šค์™€ ๋ฐ˜์‘ ์†๋„๋ฅผ ๋ชจ๋‘ ์žก์€ ์ตœ์ƒ์˜ ๊ฒŒ์ด๋ฐ ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.GOOD]: { + tags: ['GPU'], + text: '์พŒ์ ํ•œ ๊ฒŒ์ด๋ฐ์ด ๊ฐ€๋Šฅํ•œ ๊ตฌ์„ฑ์ž…๋‹ˆ๋‹ค. ์™ธ์žฅ GPU ์‚ฌ์–‘์€ ์ถฉ์กฑํ•˜๋‚˜, ๋ธ”๋ฃจํˆฌ์Šค ์ „์šฉ ์ž…๋ ฅ ์žฅ์น˜๋ฅผ ์‚ฌ์šฉ ์ค‘์ผ ๊ฒฝ์šฐ ๋ฐ˜์‘ ์†๋„์— ๋ฏผ๊ฐํ•œ ๊ฒŒ์ž„์—์„œ ๋ฏธ์„ธํ•œ ์ง€์—ฐ์ด ๋А๊ปด์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }, + [GRADE.NORMAL]: { + tags: ['๋ฌด์„  ์ง€์—ฐ ์ฃผ์˜', '์‚ฌ์–‘ ๋ณด์™„ ์ถ”์ฒœ'], + text: '๊ณ ์‚ฌ์–‘ ๊ฒŒ์ž„์—๋Š” ๋ถ€์กฑํ•  ์ˆ˜ ์žˆ๋Š” ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค. ๋‚ด์žฅ ๊ทธ๋ž˜ํ”ฝ์œผ๋กœ ์ธํ•ด ๊ณ ์‚ฌ์–‘ ๊ฒŒ์ž„ ๊ตฌ๋™์ด ์–ด๋ ต๊ฑฐ๋‚˜, ๋ธ”๋ฃจํˆฌ์Šค ์ „์šฉ ์ž…๋ ฅ ์žฅ์น˜๋กœ ์ธํ•ด ๋ฏธ์„ธํ•œ ์ง€์—ฐ์ด ๋А๊ปด์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }, + [GRADE.POOR]: { + tags: ['GPU ๋ฏธ๋‹ฌ', '์ง€์—ฐ ๋ฐœ์ƒ', '๊ฒŒ์ž„ ๋ถ€์ ํ•ฉ'], + text: '์„ ํƒ๋œ ๊ธฐ๊ธฐ ์ค‘ Game์— ๋งž์ง€ ์•Š๋Š” ์š”์†Œ๊ฐ€ ๋ฐœ๊ฒฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋‚ด์žฅ ๊ทธ๋ž˜ํ”ฝ์˜ ํ•œ๊ณ„๋กœ ํ”„๋ ˆ์ž„ ๋“œ๋ž์ด ์žฆ๊ฑฐ๋‚˜, ์ง€์—ฐ์ด ๋ฐœ์ƒํ•˜์—ฌ ์›ํ™œํ•œ ํ”Œ๋ ˆ์ด๊ฐ€ ์–ด๋ ค์šด ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.UNKNOWN]: { + tags: ['-'], + text: '-', + }, + }, + + Study: { + [GRADE.BEST]: { + tags: ['๋ฌด์†Œ์Œ ์กฐํ•ฉ', 'ํ•„๊ธฐ ์ตœ์ ํ™”'], + text: '๋„์„œ๊ด€์ด๋‚˜ ๋…์„œ์‹ค์—์„œ๋„ ์•ˆ์‹ฌํ•˜๊ณ  ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๋ฌด์†Œ์Œ ํ™˜๊ฒฝ์„ ๊ฐ–์ท„์Šต๋‹ˆ๋‹ค. ํŽœ ํ•„๊ธฐ๊ฐ€ ์ง€์›๋˜์–ด ๋””์ง€ํ„ธ ํ•™์Šต๊ณผ ํ•„๊ธฐ์— ์ตœ์ ํ™”๋œ ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.GOOD]: { + tags: ['ํŽœ ํ™œ์šฉ ๊ฐ€๋Šฅ', 'ํ•™์Šต ํšจ์œจ ์šฐ์ˆ˜'], + text: '์ „๋ฐ˜์ ์œผ๋กœ ์กฐ์šฉํ•œ ํ•™์Šต ํ™˜๊ฒฝ์— ์ ํ•ฉํ•œ ๊ตฌ์„ฑ์ž…๋‹ˆ๋‹ค. ํŽœ์„ ํ™œ์šฉํ•œ ํ•„๊ธฐ๊ฐ€ ๊ฐ€๋Šฅํ•˜๋ฉฐ, ์ž…๋ ฅ ์žฅ์น˜์˜ ์†Œ์Œ์ด ์ ์–ด ๊ณต๊ณต์žฅ์†Œ์—์„œ ๋ฌด๋‚œํ•˜๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.NORMAL]: { + tags: ['์†Œ์Œ ํ™•์ธ ํ•„์š”', 'ํ•„๊ธฐ ๋ณด์™„ ์ถ”์ฒœ'], + text: '๊ธฐ๋ณธ์ ์ธ ํ•™์Šต์€ ๊ฐ€๋Šฅํ•˜๋‚˜ ํ™˜๊ฒฝ์— ๋”ฐ๋ผ ์ฃผ์˜๊ฐ€ ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํŽœ ํ•„๊ธฐ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š๊ฑฐ๋‚˜, ํ‚ค๋ณด๋“œ/๋งˆ์šฐ์Šค์˜ ์†Œ์Œ์ด ์กด์žฌํ•˜์—ฌ, ์กฐ์šฉํ•œ ๊ณต๊ฐ„์—์„œ๋Š” ์‚ฌ์šฉ์ด ์ œํ•œ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }, + [GRADE.POOR]: { + tags: ['์†Œ์Œ ์ฃผ์˜', 'ํŽœ ๋ฏธ์ง€์›', '์žฅ์†Œ ์ œ์•ฝ'], + text: '์„ ํƒ๋œ ๊ธฐ๊ธฐ ์ค‘ ํ•™์Šต ์ง‘์ค‘๋„๋ฅผ ๋–จ์–ด๋œจ๋ฆฌ๋Š” ์š”์†Œ๊ฐ€ ๋ฐœ๊ฒฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์†Œ์Œ์ด ํฐ ํ‚ค๋ณด๋“œ๋ฅผ ์‚ฌ์šฉ ์ค‘์ด๊ฑฐ๋‚˜ ํŽœ ํ•„๊ธฐ๊ฐ€ ๋ถˆ๊ฐ€๋Šฅํ•˜์—ฌ, ์กฐ์šฉํ•œ ํ™˜๊ฒฝ์—์„  ๋ถ€์ ํ•ฉํ•œ ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.UNKNOWN]: { + tags: ['-'], + text: '-', + }, + }, + + 'Video-editing': { + [GRADE.BEST]: { + tags: ['4KํŽธ์ง‘', 'GPU'], + text: '๊ณ ์‚ฌ์–‘ ์˜์ƒ ์ž‘์—…์— ํ•„์š”ํ•œ ๋žจ๊ณผ ์™ธ์žฅ ๊ทธ๋ž˜ํ”ฝ์„ ๋ชจ๋‘ ๊ฐ–์ท„์Šต๋‹ˆ๋‹ค. ์ง€์—ฐ ์—†์ด ์ตœ์ ์˜ ํ™˜๊ฒฝ์—์„œ ์ž‘์—… ๊ฐ€๋Šฅํ•œ ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.GOOD]: { + tags: ['์ž‘์—… ์›ํ™œ', '์‚ฌ์–‘ ์ค€์ˆ˜', 'ํŽธ์ง‘์šฉ ๊ตฌ์„ฑ'], + text: '์˜์ƒ ํŽธ์ง‘์— ์›ํ™œํ•œ ๊ตฌ์„ฑ์ž…๋‹ˆ๋‹ค. ๋†’์€ ๋žจ ์šฉ๋Ÿ‰์œผ๋กœ ์พŒ์ ํ•œ ์ž‘์—…์ด ๊ฐ€๋Šฅํ•˜๋ฉฐ, ์ผ๋ถ€ ์ฃผ๋ณ€๊ธฐ๊ธฐ ๊ตฌ์„ฑ์— ๋”ฐ๋ผ ์ „๋ฌธ์ ์ธ ์ƒ‰์ •๋ฐ€์ด๋‚˜ ์žฅ์‹œ๊ฐ„ ์ž‘์—… ์‹œ ๋ณด์™„์ด ๊ณ ๋ ค๋  ์ˆ˜ ์žˆ๋Š” ๊ตฌ๊ฐ„์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.NORMAL]: { + tags: ['์ž…๋ฌธ์šฉ ์กฐํ•ฉ', '๋žจ ์šฉ๋Ÿ‰ ์ฒดํฌ', '๋‚ฎ์€ ๋ Œ๋”๋ง'], + text: '๊ฐ„๋‹จํ•œ ์ˆํผ์ด๋‚˜ ๋ธŒ์ด๋กœ๊ทธ ํŽธ์ง‘์— ์ ํ•ฉํ•œ ๊ตฌ์„ฑ์ž…๋‹ˆ๋‹ค. ํ‰๊ท ์ ์ธ ์ŠคํŽ™์œผ๋กœ ์ธํ•ด, ์žฅ๋น„ ์‚ฌ์–‘์— ๋”ฐ๋ผ ๋ Œ๋”๋ง ์†๋„์— ์ฐจ์ด๊ฐ€ ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }, + [GRADE.POOR]: { + tags: ['์‚ฌ์–‘ ๋ถ€์กฑ', 'ํŽธ์ง‘ ์ง€์—ฐ', '์žฅ๋น„ ์žฌ๊ฒ€ํ† '], + text: '์˜์ƒ ํŽธ์ง‘์— ๋น„ํ•ด ํ•˜๋“œ์›จ์–ด์˜ ์„ฑ๋Šฅ์ด ์•„์‰ฝ์Šต๋‹ˆ๋‹ค. ๋‚ฎ์€ ๋žจ ์šฉ๋Ÿ‰์ด๋‚˜ ๊ทธ๋ž˜ํ”ฝ ์„ฑ๋Šฅ์˜ ํ•œ๊ณ„๋กœ ์ธํ•ด ์˜์ƒ ์ž‘์—… ์‹œ ์ง€์—ฐ์ด ๋ฐœ์ƒํ•  ํ™•๋ฅ ์ด ๋†’์€ ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.UNKNOWN]: { + tags: ['-'], + text: '-', + }, + }, + + 'Tour/portability': { + [GRADE.BEST]: { + tags: ['์ดˆ๊ฒฝ๋Ÿ‰ ์กฐํ•ฉ', '์ถฉ์ „๊ธฐ ํ†ตํ•ฉ', 'Wireless'], + text: '์—ฌํ–‰์— ์ตœ์ ํ™”๋œ ๊ฐ€๋ฒผ์šด ๋ฌด๊ฒŒ์™€ ๋ฏธ๋‹ˆ๋ฉ€ํ•œ ๊ตฌ์„ฑ์ž…๋‹ˆ๋‹ค. ์ „์šฉ ์–ด๋Œ‘ํ„ฐ ์—†์ด ์ถฉ์ „๊ธฐ ํ•˜๋‚˜๋กœ ๋ชจ๋“  ๊ธฐ๊ธฐ๋ฅผ ์ปค๋ฒ„ํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์„  ์—†๋Š” ์ž์œ ๋กœ์šด ์‚ฌ์šฉ์ด ๊ฐ€๋Šฅํ•œ ์™„๋ฒฝํ•œ ์—ฌํ–‰์šฉ ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.GOOD]: { + tags: ['ํœด๋Œ€์„ฑ', 'Wireless'], + text: '์žฅ์‹œ๊ฐ„ ์ด๋™์—๋„ ํฐ ๋ถ€๋‹ด์ด ์—†๋Š” ๊ตฌ์„ฑ์ž…๋‹ˆ๋‹ค. ์ „์ฒด์ ์ธ ๋ฌด๊ฒŒ๊ฐ€ ๊ฐ€๋ณ๊ณ  ๋ฌด์„  ์—ฐ๊ฒฐ ๊ธฐ๋Šฅ์„ ๊ฐ–์ถ”๊ณ  ์žˆ์–ด, ์นดํŽ˜๋‚˜ ๊ณตํ•ญ ๋“ฑ ์™ธ๋ถ€ ํ™˜๊ฒฝ์—์„œ ์œ ์—ฐํ•˜๊ฒŒ ์‚ฌ์šฉํ•˜๊ธฐ ์ข‹์€ ํ™˜๊ฒฝ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.NORMAL]: { + tags: ['๋ถ€ํ”ผ ์กฐ์ ˆ', '์–ด๋Œ‘ํ„ฐ ์ฒดํฌ'], + text: '์ผ์ƒ์ ์ธ ํœด๋Œ€๋Š” ๊ฐ€๋Šฅํ•˜๋‚˜ ์—ฌํ–‰ ์‹œ ๊ธฐ๊ธฐ๋“ค์˜ ๋ถ€ํ”ผ๋กœ ์ธํ•ด ํ”ผ๋กœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ธฐ๊ธฐ๋“ค์˜ ๋ฌด๊ฒŒ๋กœ ์ธํ•ด, ๋‹จ๊ฑฐ๋ฆฌ ์ด๋™์ด๋‚˜ ์‹ค๋‚ด ์‚ฌ์šฉ์— ๋” ์ ํ•ฉํ•œ ๊ตฌ์„ฑ์ž…๋‹ˆ๋‹ค.', + }, + [GRADE.POOR]: { + tags: ['๋‚ฎ์€ ํœด๋Œ€์„ฑ', '์–ด๋Œ‘ํ„ฐ ํ•„์ˆ˜', '์—ฌํ–‰ ํ”ผ๋กœ'], + text: '์ด๋™์ด ์žฆ์€ ์—ฌํ–‰์—๋Š” ๋ถ€๋‹ด์ด ํฐ ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค. ๊ธฐ๊ธฐ๋“ค์˜ ๋ฌด๊ฒŒ๊ฐ€ ๋ฌด๊ฒ๊ฑฐ๋‚˜, ๋…ธํŠธ๋ถ ์ถฉ์ „์— ๋ฌด๊ฑฐ์šด ์–ด๋Œ‘ํ„ฐ๊ฐ€ ์‚ฌ์šฉ๋˜๋Š” ๋“ฑ, ํœด๋Œ€ ํšจ์œจ์ด ๋‚ฎ์€ ์š”์†Œ๋“ค์ด ๋ฐœ๊ฒฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + }, + [GRADE.UNKNOWN]: { + tags: ['-'], + text: '-', + }, + }, +}; diff --git a/src/constants/lifestyle.ts b/src/constants/lifestyle.ts new file mode 100644 index 00000000..7687e476 --- /dev/null +++ b/src/constants/lifestyle.ts @@ -0,0 +1,60 @@ +import Office from '@/assets/images/lifestyle/office.jpg'; +import Developer from '@/assets/images/lifestyle/developer.jpg'; +import Game from '@/assets/images/lifestyle/game.jpg'; +import Study from '@/assets/images/lifestyle/study.jpg'; +import VideoEditing from '@/assets/images/lifestyle/video-editing.jpg'; +import Tour from '@/assets/images/lifestyle/tour.jpg'; +import type { LifestyleTagKey } from '@/types/lifestyle/lifestyle'; + +export const LIFESTYLE_CONFIG = { + Office: { + image: Office, + tagKey: 'Office', + }, + Developer: { + image: Developer, + tagKey: 'Developer', + }, + Game: { + image: Game, + tagKey: 'Game', + }, + Study: { + image: Study, + tagKey: 'Study', + }, + 'Video-editing': { + image: VideoEditing, + tagKey: 'Video-editing', + }, + 'Tour/portability': { + image: Tour, + tagKey: 'Tour', + }, +} as const satisfies Record< + string, + { + image: string; + tagKey: LifestyleTagKey; + } +>; + +export type LifestyleLabel = keyof typeof LIFESTYLE_CONFIG; + +export const LIFESTYLE_TAGS = Object.keys(LIFESTYLE_CONFIG) as LifestyleLabel[]; + +// label -> image +export const LIFESTYLE_TAG_IMAGE_MAP = Object.fromEntries( + Object.entries(LIFESTYLE_CONFIG).map(([label, { image }]) => [label, image]) +) as Record; + +// label -> tagkey +export const LIFESTYLE_LABEL_TO_TAGKEY = Object.fromEntries( + Object.entries(LIFESTYLE_CONFIG).map(([label, { tagKey }]) => [label, tagKey]) +) as Record; + +// label -> tag +export type LifestyleDisplayTag = `# ${LifestyleLabel}`; +export const LIFESTYLE_DISPLAY_TAGS = LIFESTYLE_TAGS.map( + (t) => `# ${t}` as const +) as LifestyleDisplayTag[]; \ No newline at end of file diff --git a/src/constants/queryKey.ts b/src/constants/queryKey.ts new file mode 100644 index 00000000..e1886342 --- /dev/null +++ b/src/constants/queryKey.ts @@ -0,0 +1,15 @@ +// React Query queryKey ์ƒ์ˆ˜ ๊ด€๋ฆฌ +// ํ•œ ๊ฐ์ฒด์—์„œ "ํ‚ค ๋ฌธ์ž์—ด"๋งŒ ๊ด€๋ฆฌํ•˜๊ณ  +// ์‹ค์ œ queryKey ๋ฐฐ์—ด์€ ๊ฐ ์‚ฌ์šฉ์ฒ˜์—์„œ [queryKey.X] ํ˜•ํƒœ๋กœ ์กฐ๋ฆฝํ•ฉ๋‹ˆ๋‹ค. + +export const queryKey = { + USER_PROFILE: 'user_profile', + TAGS: 'tags', + COMBOS: 'combos', + COMBO_DETAIL: 'combo', + COMBO_EVALUATION: 'combo_evaluation', + LIFESTYLE_DEVICE: 'lifestyle_device', + BRANDS: 'brands', + RECENTLY_VIEWED: 'recently_viewed', + DEVICE_SEARCH: 'device_search', +} as const; diff --git a/src/constants/routes.ts b/src/constants/routes.ts new file mode 100644 index 00000000..5f4cb930 --- /dev/null +++ b/src/constants/routes.ts @@ -0,0 +1,68 @@ +// ์•ฑ ์ „์ฒด ๋ผ์šฐํŠธ ๊ฒฝ๋กœ๋ฅผ ํ•œ ๊ณณ์—์„œ ๊ด€๋ฆฌํ•˜๊ธฐ ์œ„ํ•œ ์ƒ์ˆ˜ ๋ชจ์Œ + +export const ROUTES = { + // root + home: '/', + + // auth flow + auth: { + base: '/auth', + login: '/auth/login', + findId: '/auth/find/id', + findIdResult: '/auth/find/id/result', + findPassword: '/auth/find/password', + signup: { + base: '/auth/signup', + account: '/auth/signup/account', + profile: '/auth/signup/profile', + }, + callback: { + google: '/auth/callback/google', + }, + }, + + // onboarding + onboarding: { + lifestyle: '/onboarding/lifestyle', + combination: '/onboarding/combination', + complete: '/onboarding/complete', + }, + + // recommendation (์˜จ๋ณด๋”ฉ ์™„๋ฃŒ ํ›„ ์ ‘๊ทผ ๊ฐ€๋Šฅ) + recommendation: '/recommendation', + + // lifestyle + lifestyle: '/lifestyle', + + // devices + devices: '/devices', + deviceDetail: (deviceId: string) => `/devices/${deviceId}`, + //navigate(ROUTES.deviceDetail(deviceId)); ์ด๋Ÿฐ์‹์œผ๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ + + // combination + combination: { + create: '/combination/create', + }, + + // my + my: { + base: '/my', + combinationDetail: (id: string) => `/my/combinations/${id}`, + // navigate(ROUTES.my.combinationDetail(id)); ์ด๋Ÿฐ์‹์œผ๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ + settings: { + profile: '/my/settings/profile', + password: '/my/settings/password', + }, + trash: '/my/trash', + }, + + // support (footer / settings ๊ณต์šฉ) + // support: { + // base: '/support', + // customerCenter: '/support/customer-center', + // faq: '/support/faq', + // notices: '/support/notices', + // terms: '/support/terms', + // privacyPolicy: '/support/privacy-policy', + // }, +} as const; diff --git a/src/constants/tagGroup.ts b/src/constants/tagGroup.ts new file mode 100644 index 00000000..a26a349e --- /dev/null +++ b/src/constants/tagGroup.ts @@ -0,0 +1,30 @@ +// ํƒœ๊ทธ type๋ณ„ ์ƒ์ˆ˜ + +export const TAG_GROUP_BY_KEY = { + // ์ค‘์š”ํ•˜๊ฒŒ ์ƒ๊ฐํ•˜๋Š” ๊ฒƒ์€? + interest: new Set([ + 'Performance', + 'Value', + 'Portability', + 'BatteryLife', + 'DesignColor', + ]), + + // ๋‚˜์˜ ์ฃผ๋œ ์šฉ๋„๋Š”? + lifestyle: new Set([ + 'Office', + 'Tour', + 'Developer', + 'Game', + 'Study', + 'Video-editing' + ]), + + // ์„ ํ˜ธํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋Š”? + brand: new Set([ + 'Apple', + 'Samsung', + 'Sony', + 'Logitech', + 'Any']), +} as const; diff --git a/src/constants/time.ts b/src/constants/time.ts new file mode 100644 index 00000000..ccb8f19d --- /dev/null +++ b/src/constants/time.ts @@ -0,0 +1,3 @@ +export const ROTATION_MS = 3000; +export const TRANSITION_MS = 700; +export const USER_INTERACTION_PAUSE_MS = 6000; \ No newline at end of file diff --git a/src/constants/tokenKey.ts b/src/constants/tokenKey.ts new file mode 100644 index 00000000..e91ecc60 --- /dev/null +++ b/src/constants/tokenKey.ts @@ -0,0 +1,7 @@ +// ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ํ‚ค ์ƒ์ˆ˜ + +// ํ† ํฐ ๊ด€๋ จ ํ‚ค ์ƒ์ˆ˜ +export const ACCESS_TOKEN = 'ACCESS_TOKEN'; +//๋กœ๊ทธ์ธ ์œ ์ง€ ์—ฌ๋ถ€์— ๋”ฐ๋ฅธ ์ €์žฅ์†Œ ๊ตฌ๋ถ„: 'local' |'session' +export const AUTH_STORAGE = 'auth_storage'; + diff --git a/src/hooks/useAddToCombination.ts b/src/hooks/useAddToCombination.ts new file mode 100644 index 00000000..ebbc7a72 --- /dev/null +++ b/src/hooks/useAddToCombination.ts @@ -0,0 +1,312 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { type ModalView } from '@/types/devices'; +import { type ComboDevice } from '@/types/combo/combo'; +import { ROUTES } from '@/constants/routes'; +import { useGetCombos } from '@/apis/combo/getCombos'; +import { useGetCombo } from '@/apis/combo/getComboId'; +import { usePostComboDevice } from '@/apis/combo/postComboDevices'; +import { usePostRecentlyViewed } from '@/apis/recentlyViewed/postRecentlyViewed'; +import { useAuth } from '@/hooks/useAuth'; +import { getBaseModelName } from '@/utils/devices/getBaseModelName'; + +interface UseAddToCombinationParams { + selectedProductId: string | null; + selectedDeviceType?: string | null; + selectedDeviceName?: string | null; + onCloseModal: () => void; +} + +interface AddToCombinationConfig { + text: string; + handler: () => void; + disabled?: boolean; +} + +interface DuplicateCheckResult { + isBlocked: boolean; + reason: 'model' | 'category' | null; +} + +export const useAddToCombination = ({ + selectedProductId, + selectedDeviceType, + selectedDeviceName, + onCloseModal, +}: UseAddToCombinationParams) => { + const navigate = useNavigate(); + + // ์ธ์ฆ ์ƒํƒœ ํ™•์ธ + const { isLoggedIn, hasCompletedOnboarding, isAuthLoading } = useAuth(); + + // ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ ์—ฌ๋ถ€ (๋กœ๋”ฉ ์ค‘์—๋Š” false๋กœ ๊ธฐ๋ณธ ์ฒ˜๋ฆฌ) + const hasOnboarding = isAuthLoading ? false : hasCompletedOnboarding; + + const [modalView, setModalView] = useState('device'); + + // API hooks + const { data: combos = [] } = useGetCombos(); + const { mutate: addDeviceToCombo, isPending: isAddingDevice } = usePostComboDevice(); + const { mutate: recordRecentlyViewed } = usePostRecentlyViewed(); + + // ์กฐํ•ฉ์ด 1๊ฐœ์ผ ๋•Œ ํ•ด๋‹น ์กฐํ•ฉ์˜ ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ + const singleComboId = combos.length === 1 ? combos[0].comboId : null; + const { data: singleComboDetail } = useGetCombo(singleComboId); + + const [selectedCombinationId, setSelectedCombinationId] = useState(null); + const [showAllDevices, setShowAllDevices] = useState(false); + const [showSaveCompleteModal, setShowSaveCompleteModal] = useState(false); + const [isFadingOut, setIsFadingOut] = useState(false); + + // ๋ชจ๋‹ฌ ์—ด๋ฆด ๋•Œ ์ตœ๊ทผ ๋ณธ ๊ธฐ๊ธฐ ๊ธฐ๋ก (๋กœ๊ทธ์ธ ์ƒํƒœ์—์„œ๋งŒ) + useEffect(() => { + if (isLoggedIn && selectedProductId) { + recordRecentlyViewed(Number(selectedProductId)); + } + }, [selectedProductId]); + + // ์„ ํƒ๋œ ์กฐํ•ฉ์˜ ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ + const { data: comboDetail } = useGetCombo(selectedCombinationId); + + /* ์ค‘๋ณต ์ฒดํฌ ์œ ํ‹ธ๋ฆฌํ‹ฐ */ + const DEVICE_TYPE_MAP: Record = { + 'SMARTPHONE': ['SMARTPHONE', 'PHONE', '์Šค๋งˆํŠธํฐ', 'ํฐ'], + 'LAPTOP': ['LAPTOP', '๋…ธํŠธ๋ถ'], + 'TABLET': ['TABLET', 'ํƒœ๋ธ”๋ฆฟ'], + 'CHARGER': ['CHARGER', '์ถฉ์ „๊ธฐ'], + 'AUDIO': ['AUDIO', 'EARBUDS', 'EARBUD', 'HEADPHONE', 'HEADPHONES', 'EARPHONE', 'EARPHONES', '์ด์–ด๋ฒ„๋“œ', '์ด์–ดํฐ', 'ํ—ค๋“œํฐ', '์ด์–ดํฐ/ํ—ค๋“œํฐ', '์˜ค๋””์˜ค'], + 'SMARTWATCH': ['SMARTWATCH', 'WATCH', '์›Œ์น˜', '์‹œ๊ณ„', '์Šค๋งˆํŠธ์›Œ์น˜'], + 'KEYBOARD': ['KEYBOARD', 'ํ‚ค๋ณด๋“œ'], + 'MOUSE': ['MOUSE', '๋งˆ์šฐ์Šค'], + }; + + const isSameDeviceType = (type1: string, type2: string): boolean => { + if (type1 === type2) return true; + + for (const types of Object.values(DEVICE_TYPE_MAP)) { + if (types.includes(type1) && types.includes(type2)) { + return true; + } + } + + return false; + }; + + const checkDeviceDuplicate = ( + existingDevices: ComboDevice[], + targetDeviceType: string, + targetDeviceName: string + ): DuplicateCheckResult => { + if (!targetDeviceType || !targetDeviceName) { + return { isBlocked: false, reason: null }; + } + + const targetBaseName = getBaseModelName(targetDeviceName); + + // ์šฐ์„ ์ˆœ์œ„ 1: ๊ฐ™์€ ๋ชจ๋ธ + for (const device of existingDevices) { + const deviceBaseName = getBaseModelName(device.name); + if (deviceBaseName === targetBaseName) { + return { isBlocked: true, reason: 'model' }; + } + } + + // ์šฐ์„ ์ˆœ์œ„ 2: ๊ฐ™์€ ์นดํ…Œ๊ณ ๋ฆฌ + for (const device of existingDevices) { + if (isSameDeviceType(device.deviceType, targetDeviceType)) { + return { isBlocked: true, reason: 'category' }; + } + } + + return { isBlocked: false, reason: null }; + }; + + /* ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ */ + const handleCloseModal = () => { + onCloseModal(); + setModalView('device'); + setSelectedCombinationId(null); + setShowAllDevices(false); + }; + + // ์—๋Ÿฌ ํ•ธ๋“ค๋Ÿฌ (๊ณตํ†ต) + const handleComboError = (_error: any) => { + // ์—๋Ÿฌ ์ฒ˜๋ฆฌ ๋กœ์ง ํ•„์š”์‹œ ์ถ”๊ฐ€ + }; + + /* ๋‹จ์ผ ์กฐํ•ฉ์ผ ๋•Œ ์ค‘๋ณต ์ฒดํฌ */ + const singleComboDuplicateCheck = (() => { + if (combos.length !== 1 || !singleComboDetail) { + return { isBlocked: false, reason: null }; + } + + return checkDeviceDuplicate( + singleComboDetail.devices, + selectedDeviceType ?? '', + selectedDeviceName ?? '' + ); + })(); + + /* ๋‚ด ์กฐํ•ฉ์— ๋‹ด๊ธฐ */ + const handleAddToCombination = () => { + // ์กฐํ•ฉ์ด 1๊ฐœ๋ฉด ๋ฐ”๋กœ ์ €์žฅ + if (combos.length === 1 && selectedProductId) { + addDeviceToCombo( + { comboId: combos[0].comboId, deviceId: Number(selectedProductId) }, + { + onSuccess: () => { + setModalView('device'); + setShowSaveCompleteModal(true); + }, + onError: handleComboError, + } + ); + return; + } + + // ์กฐํ•ฉ์ด 2๊ฐœ ์ด์ƒ์ด๋ฉด ์„ ํƒ ๋ชจ๋‹ฌ ํ‘œ์‹œ + setModalView('combination'); + }; + + /* ๋ฒ„ํŠผ ํ…์ŠคํŠธ ๋ฐ ํ•ธ๋“ค๋Ÿฌ ๊ฒฐ์ • */ + const getAddToCombinationConfig = (): AddToCombinationConfig => { + // Case 1: ๋กœ๊ทธ์•„์›ƒ ์ƒํƒœ + if (!isLoggedIn) { + return { + text: '๋กœ๊ทธ์ธํ•˜๊ณ  ๋‚ด ์กฐํ•ฉ์— ๋‹ด๊ธฐ', + handler: () => { + navigate(ROUTES.auth.login); + }, + disabled: false, + }; + } + + // Case 2: ๋กœ๊ทธ์ธํ–ˆ์ง€๋งŒ ์˜จ๋ณด๋”ฉ ๋ฏธ์™„๋ฃŒ + if (!hasOnboarding) { + return { + text: '๋งž์ถค ์„ค์ •ํ•˜๊ณ  ๋‹ด๊ธฐ', + handler: () => { + navigate(ROUTES.onboarding.lifestyle); + }, + disabled: false, + }; + } + + // Case 3: ๋กœ๊ทธ์ธ + ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ + ์กฐํ•ฉ 1๊ฐœ + if (combos.length === 1) { + const isBlocked = singleComboDuplicateCheck.isBlocked; + const reason = singleComboDuplicateCheck.reason; + + let text = '๋‚ด ์กฐํ•ฉ์— ๋‹ด๊ธฐ'; + if (isBlocked && reason === 'model') { + text = '์ด๋ฏธ ๋‹ด์€ ๊ธฐ๊ธฐ์ž…๋‹ˆ๋‹ค'; + } else if (isBlocked && reason === 'category') { + text = '์ด๋ฏธ ๋‹ด์€ ํƒ€์ž…์ž…๋‹ˆ๋‹ค'; + } + + return { + text, + handler: handleAddToCombination, + disabled: isBlocked, + }; + } + + // Case 4: ๋กœ๊ทธ์ธ + ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ + ์กฐํ•ฉ 2๊ฐœ ์ด์ƒ + return { + text: '๋‚ด ์กฐํ•ฉ์— ๋‹ด๊ธฐ', + handler: handleAddToCombination, + disabled: false, + }; + }; + + const addToCombinationConfig = getAddToCombinationConfig(); + + /* ์กฐํ•ฉ ์„ ํƒ - ๊ธฐ๊ธฐ ๋ฆฌ์ŠคํŠธ ๋ณด๊ธฐ */ + const handleSelectCombination = (combinationId: number) => { + setSelectedCombinationId(combinationId); + setShowAllDevices(false); + setModalView('combinationDetail'); + }; + + /* ์กฐํ•ฉ์— ๊ธฐ๊ธฐ ๋‹ด๊ธฐ */ + const handleAddDeviceToCombination = () => { + if (selectedCombinationId && selectedProductId) { + addDeviceToCombo( + { comboId: selectedCombinationId, deviceId: Number(selectedProductId) }, + { + onSuccess: () => { + setModalView('device'); + setShowSaveCompleteModal(true); + }, + onError: handleComboError, + } + ); + } + }; + + /* ์ €์žฅ ์™„๋ฃŒ ๋ชจ๋‹ฌ ์ž๋™ ๋‹ซ๊ธฐ */ + useEffect(() => { + if (showSaveCompleteModal) { + // 1. 0.8์ดˆ ์œ ์ง€ + const holdTimer = setTimeout(() => { + setIsFadingOut(true); + + // 2. 0.2์ดˆ ๋™์•ˆ dissolve (fade-out) ํ›„ ์ข…๋ฃŒ + const closeTimer = setTimeout(() => { + setShowSaveCompleteModal(false); + setIsFadingOut(false); + handleCloseModal(); + }, 200); + + return () => clearTimeout(closeTimer); + }, 800); + + return () => clearTimeout(holdTimer); + } + }, [showSaveCompleteModal]); + + /* ์„ ํƒ๋œ ์กฐํ•ฉ ์ •๋ณด */ + const selectedCombination = selectedCombinationId + ? combos.find(c => c.comboId === selectedCombinationId) + : null; + + /* ์„ ํƒ๋œ ์กฐํ•ฉ์˜ ๊ธฐ๊ธฐ ๋ฆฌ์ŠคํŠธ (API์—์„œ ์กฐํšŒ) */ + const combinationDevices = comboDetail?.devices || []; + + /* ์„ ํƒ๋œ ์กฐํ•ฉ์— ์ด๋ฏธ ๋‹ด๊ธด ๊ธฐ๊ธฐ์ธ์ง€ ํ™•์ธ */ + const duplicateCheck = (() => { + if (!selectedCombinationId || !selectedDeviceType || !selectedDeviceName) { + return { isBlocked: false, reason: null }; + } + + return checkDeviceDuplicate( + combinationDevices, + selectedDeviceType, + selectedDeviceName + ); + })(); + + const isAlreadyInSelectedCombination = duplicateCheck.isBlocked; + const duplicateReason = duplicateCheck.reason; + + return { + modalView, + setModalView, + showSaveCompleteModal, + isFadingOut, + combos, + selectedCombination, + combinationDevices, + selectedCombinationId, + showAllDevices, + setShowAllDevices, + isAlreadyInSelectedCombination, + duplicateReason, + isAddingDevice, + addToCombinationConfig, + isProfileLoading: isAuthLoading, + handleCloseModal, + handleSelectCombination, + handleAddDeviceToCombination, + }; +}; diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 00000000..fc935992 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,47 @@ +import { useGetUserProfile } from '@/apis/mypage/getUserProfile'; +import { hasAccessToken } from '@/utils/authStorage'; +import type { UserProfileResult } from '@/types/mypage/user'; + +// UserProfile ํƒ€์ž… ๋ณ„์นญ (UserProfileResult์™€ ๋™์ผ) +export type UserProfile = UserProfileResult; + +/* +์ธ์ฆ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ํ›… (RootLayout์—์„œ ํŠธ๋ฆฌ๊ฑฐํ•œ user profile ์ฟผ๋ฆฌ ๊ตฌ๋…) + +๋กœ๊ทธ์ธ ์—ฌ๋ถ€ ํŒ๋‹จ ๋กœ์ง: + - ํ† ํฐ์ด ์žˆ๊ณ  userProfile์ด ์žˆ์„ ๋•Œ๋งŒ โ†’ ๋กœ๊ทธ์ธ ์ƒํƒœ + - ๊ทธ ์™ธ์˜ ๊ฒฝ์šฐ โ†’ ๋น„๋กœ๊ทธ์ธ ์ƒํƒœ + +๋ฐ˜ํ™˜๊ฐ’: + - isLoggedIn: ๋กœ๊ทธ์ธ ์—ฌ๋ถ€ (ํ† ํฐ + user๊ฐ€ ๋ชจ๋‘ ์žˆ์„ ๋•Œ๋งŒ true) + - user: ์œ ์ € ๊ฐ์ฒด (์—†์œผ๋ฉด null) + - isAuthLoading: ์ธ์ฆ ๋กœ๋”ฉ ์ƒํƒœ (ํ† ํฐ์€ ์žˆ๋Š”๋ฐ me๋ฅผ ์•„์ง ๋ชป ๋ฐ›์•„์˜จ ์ƒํƒœ) + - hasCompletedOnboarding: ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ ์—ฌ๋ถ€ (API์˜ isOnboardingCompleted ์‚ฌ์šฉ) + */ + +export const useAuth = () => { + const hasToken = hasAccessToken(); + const { data: user, isLoading, refetch } = useGetUserProfile(); + + // ์ธ์ฆ ๊ด€๋ จ ์ดˆ๊ธฐ ๋กœ๋”ฉ ์ƒํƒœ + // - ํ† ํฐ์ด ์žˆ์„ ๋•Œ: userProfile ์กฐํšŒ ์ค‘ (isLoading = true) + // - ํ† ํฐ์ด ์—†์„ ๋•Œ: enabled=false์ด๋ฏ€๋กœ isLoading = false + // - ์ดˆ๊ธฐ ๋งˆ์šดํŠธ ์‹œ: isLoading์ด true์ผ ์ˆ˜ ์žˆ์Œ + // - isFetching์€ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๋ฆฌํŽ˜์น˜๋ฅผ ๋‚˜ํƒ€๋‚ด๋ฏ€๋กœ ๊ฐ€๋“œ์—์„œ๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ + const isAuthLoading = isLoading; + + // ํ† ํฐ์ด ์žˆ๊ณ  userProfile์ด ์žˆ์„ ๋•Œ๋งŒ ๋กœ๊ทธ์ธ ์ƒํƒœ + const isLoggedIn = hasToken && !!user; + + // ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ ์—ฌ๋ถ€ (API์˜ isOnboardingCompleted ์‚ฌ์šฉ) + const hasCompletedOnboarding = !!user?.isOnboardingCompleted; + + return { + isLoggedIn, + user: user ?? null, + isAuthLoading, + hasToken, + hasCompletedOnboarding, + refetchUserProfile: refetch, + }; +}; diff --git a/src/hooks/useAutoRotate.ts b/src/hooks/useAutoRotate.ts new file mode 100644 index 00000000..408055f2 --- /dev/null +++ b/src/hooks/useAutoRotate.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +type Options = { + enabled: boolean; + intervalMs: number; + setValue: React.Dispatch>; + getNext: (prev: T) => T; +}; + +export const useAutoRotate = ({ enabled, intervalMs, setValue, getNext }: Options) => { + useEffect(() => { + if (!enabled) return; + + const id = window.setInterval(() => { + setValue((prev) => getNext(prev)); + }, intervalMs); + + return () => window.clearInterval(id); + }, [enabled, intervalMs, setValue, getNext]); +}; diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts new file mode 100644 index 00000000..9321961e --- /dev/null +++ b/src/hooks/useClickOutside.ts @@ -0,0 +1,22 @@ +import { useEffect, type RefObject } from 'react'; + +/** + * ์š”์†Œ ์™ธ๋ถ€ ํด๋ฆญ์„ ๊ฐ์ง€ํ•˜๋Š” ํ›… + * @param ref - ๊ฐ์ง€ํ•  ์š”์†Œ์˜ ref + * @param handler - ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ์‹คํ–‰ํ•  ์ฝœ๋ฐฑ + */ +export const useClickOutside = ( + ref: RefObject, + handler: (event: MouseEvent) => void +) => { + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + handler(event); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [ref, handler]); +}; diff --git a/src/hooks/useCombinationEdit.ts b/src/hooks/useCombinationEdit.ts new file mode 100644 index 00000000..84bd4f71 --- /dev/null +++ b/src/hooks/useCombinationEdit.ts @@ -0,0 +1,106 @@ +import { useState, useCallback, useMemo } from 'react'; +import type { ComboListItem } from '@/types/combo/combo'; + +export interface UseCombinationEditReturn { + editingComboId: number | null; + editingCombinationName: string; + comboNameError: string | null; + isComboNameValid: boolean; + startEditing: (comboId: number, comboName: string) => void; + stopEditing: () => void; + handleComboNameChange: (e: React.ChangeEvent) => void; + validateComboName: (name: string) => string | null; + setComboNameError: (error: string | null) => void; +} + +export const useCombinationEdit = (combos: ComboListItem[]): UseCombinationEditReturn => { + const [editingComboId, setEditingComboId] = useState(null); + const [editingCombinationName, setEditingCombinationName] = useState(''); + const [comboNameError, setComboNameError] = useState(null); + + // ์กฐํ•ฉ๋ช… ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ํ•จ์ˆ˜ + const validateComboName = useCallback( + (name: string): string | null => { + // 1. ๋นˆ ๊ฐ’ ์ฒดํฌ + if (name.length === 0) { + return '์กฐํ•ฉ๋ช…์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'; + } + + // 2. ๊ณต๋ฐฑ๋งŒ ์ž…๋ ฅ ์ฒดํฌ + if (name.trim().length === 0) { + return '์กฐํ•ฉ๋ช…์„ ํ•œ ๊ธ€์ž ์ด์ƒ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'; + } + + // 3. ์ตœ๋Œ€ ๊ธธ์ด ์ฒดํฌ (20์ž) + if (name.length > 20) { + return '์กฐํ•ฉ๋ช…์€ ์ตœ๋Œ€ 20์ž๊นŒ์ง€ ์ž…๋ ฅ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.'; + } + + // 4. ์ค‘๋ณต ์ฒดํฌ (ํ˜„์žฌ ์ˆ˜์ • ์ค‘์ธ ์กฐํ•ฉ ์ œ์™ธ, trim ํ›„ ๋Œ€์†Œ๋ฌธ์ž ๊ตฌ๋ถ„ ์—†์ด ๋น„๊ต) + if (editingComboId !== null) { + const isDuplicate = combos.some( + (c) => + c.comboId !== editingComboId && + c.comboName.trim().toLowerCase() === name.trim().toLowerCase() + ); + if (isDuplicate) { + return '์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์กฐํ•ฉ๋ช…์ž…๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์ด๋ฆ„์„ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'; + } + } + + return null; // ์œ ํšจํ•จ + }, + [combos, editingComboId] + ); + + // ์กฐํ•ฉ๋ช…์ด ์œ ํšจํ•œ์ง€ ์—ฌ๋ถ€ + const isComboNameValid = useMemo(() => { + return validateComboName(editingCombinationName) === null; + }, [editingCombinationName, validateComboName]); + + // ์กฐํ•ฉ๋ช… ์ž…๋ ฅ ํ•ธ๋“ค๋Ÿฌ (๊ธธ์ด ์ œํ•œ + ์‹ค์‹œ๊ฐ„ ๊ฒ€์‚ฌ) + const handleComboNameChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + + // ์ตœ๋Œ€ ๊ธธ์ด 20์ž๋กœ ์ œํ•œ (์ž…๋ ฅ ์ž์ฒด๋ฅผ ๋ง‰์Œ) + if (newValue.length > 20) { + return; + } + + setEditingCombinationName(newValue); + + // ์‹ค์‹œ๊ฐ„ ๊ฒ€์‚ฌ (์ž…๋ ฅ ์ค‘์—๋Š” ๋นˆ ๊ฐ’/๊ณต๋ฐฑ๋งŒ ์—๋Ÿฌ๋Š” ํ‘œ์‹œํ•˜์ง€ ์•Š์Œ) + if (newValue.length > 0 && newValue.trim().length > 0) { + const error = validateComboName(newValue); + setComboNameError(error); + } else { + setComboNameError(null); + } + }; + + // ํŽธ์ง‘ ์‹œ์ž‘ + const startEditing = (comboId: number, comboName: string) => { + setEditingComboId(comboId); + setEditingCombinationName(comboName); + setComboNameError(null); + }; + + // ํŽธ์ง‘ ์ข…๋ฃŒ + const stopEditing = () => { + setEditingComboId(null); + setEditingCombinationName(''); + setComboNameError(null); + }; + + return { + editingComboId, + editingCombinationName, + comboNameError, + isComboNameValid, + startEditing, + stopEditing, + handleComboNameChange, + validateComboName, + setComboNameError, + }; +}; diff --git a/src/hooks/useCombinationModals.ts b/src/hooks/useCombinationModals.ts new file mode 100644 index 00000000..4f3b27eb --- /dev/null +++ b/src/hooks/useCombinationModals.ts @@ -0,0 +1,119 @@ +import { useState, useEffect } from 'react'; + +export interface UseCombinationModalsReturn { + // ๊ธฐ๊ธฐ ์‚ญ์ œ ๋ชจ๋‹ฌ + showDeleteModal: boolean; + openDeleteModal: () => void; + closeDeleteModal: () => void; + + // ์กฐํ•ฉ ์‚ญ์ œ ๋ชจ๋‹ฌ + showCombinationDeleteModal: boolean; + deleteTargetComboId: number | null; + openCombinationDeleteModal: (comboId: number) => void; + closeCombinationDeleteModal: () => void; + + // ์กฐํ•ฉ๋ช… ์ €์žฅ ๋ชจ๋‹ฌ + showSaveModal: boolean; + openSaveModal: () => void; + closeSaveModal: () => void; + + // ์‚ญ์ œ ์™„๋ฃŒ ํŒ์—… + showDeleteSuccessModal: boolean; + isDeleteFadingOut: boolean; + openDeleteSuccessModal: () => void; + + // ์ €์žฅ ์™„๋ฃŒ ํŒ์—… + showSaveSuccessModal: boolean; + isSaveFadingOut: boolean; + openSaveSuccessModal: () => void; +} + +export const useCombinationModals = (): UseCombinationModalsReturn => { + // ๊ธฐ๊ธฐ ์‚ญ์ œ ๋ชจ๋‹ฌ + const [showDeleteModal, setShowDeleteModal] = useState(false); + + // ์กฐํ•ฉ ์‚ญ์ œ ๋ชจ๋‹ฌ + const [showCombinationDeleteModal, setShowCombinationDeleteModal] = useState(false); + const [deleteTargetComboId, setDeleteTargetComboId] = useState(null); + + // ์กฐํ•ฉ๋ช… ์ €์žฅ ๋ชจ๋‹ฌ + const [showSaveModal, setShowSaveModal] = useState(false); + + // ์‚ญ์ œ ์™„๋ฃŒ ํŒ์—… + const [showDeleteSuccessModal, setShowDeleteSuccessModal] = useState(false); + const [isDeleteFadingOut, setIsDeleteFadingOut] = useState(false); + + // ์ €์žฅ ์™„๋ฃŒ ํŒ์—… + const [showSaveSuccessModal, setShowSaveSuccessModal] = useState(false); + const [isSaveFadingOut, setIsSaveFadingOut] = useState(false); + + // ์‚ญ์ œ ์™„๋ฃŒ ํŒ์—… ์ž๋™ ๋‹ซ๊ธฐ (0.8์ดˆ ์œ ์ง€ ํ›„ 0.2์ดˆ fade-out) + useEffect(() => { + if (showDeleteSuccessModal) { + const holdTimer = setTimeout(() => { + setIsDeleteFadingOut(true); + + const closeTimer = setTimeout(() => { + setShowDeleteSuccessModal(false); + setIsDeleteFadingOut(false); + }, 200); + + return () => clearTimeout(closeTimer); + }, 800); + + return () => clearTimeout(holdTimer); + } + }, [showDeleteSuccessModal]); + + // ์ €์žฅ ์™„๋ฃŒ ํŒ์—… ์ž๋™ ๋‹ซ๊ธฐ (0.8์ดˆ ์œ ์ง€ ํ›„ 0.2์ดˆ fade-out) + useEffect(() => { + if (showSaveSuccessModal) { + const holdTimer = setTimeout(() => { + setIsSaveFadingOut(true); + + const closeTimer = setTimeout(() => { + setShowSaveSuccessModal(false); + setIsSaveFadingOut(false); + }, 200); + + return () => clearTimeout(closeTimer); + }, 800); + + return () => clearTimeout(holdTimer); + } + }, [showSaveSuccessModal]); + + return { + // ๊ธฐ๊ธฐ ์‚ญ์ œ ๋ชจ๋‹ฌ + showDeleteModal, + openDeleteModal: () => setShowDeleteModal(true), + closeDeleteModal: () => setShowDeleteModal(false), + + // ์กฐํ•ฉ ์‚ญ์ œ ๋ชจ๋‹ฌ + showCombinationDeleteModal, + deleteTargetComboId, + openCombinationDeleteModal: (comboId: number) => { + setDeleteTargetComboId(comboId); + setShowCombinationDeleteModal(true); + }, + closeCombinationDeleteModal: () => { + setShowCombinationDeleteModal(false); + setDeleteTargetComboId(null); + }, + + // ์กฐํ•ฉ๋ช… ์ €์žฅ ๋ชจ๋‹ฌ + showSaveModal, + openSaveModal: () => setShowSaveModal(true), + closeSaveModal: () => setShowSaveModal(false), + + // ์‚ญ์ œ ์™„๋ฃŒ ํŒ์—… + showDeleteSuccessModal, + isDeleteFadingOut, + openDeleteSuccessModal: () => setShowDeleteSuccessModal(true), + + // ์ €์žฅ ์™„๋ฃŒ ํŒ์—… + showSaveSuccessModal, + isSaveFadingOut, + openSaveSuccessModal: () => setShowSaveSuccessModal(true), + }; +}; diff --git a/src/hooks/useCombinationMotion.ts b/src/hooks/useCombinationMotion.ts new file mode 100644 index 00000000..04af3543 --- /dev/null +++ b/src/hooks/useCombinationMotion.ts @@ -0,0 +1,179 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { COMBO_MOTION as M } from '@/constants/combination'; +import { + animateDrop, + copyComputedStyle, + createFloating, + fadeOutAndRemove, +} from '@/utils/combinationFloating'; + +type Setters = { + setCenterText: (v: string) => void; + setMode: (v: 'form' | 'result') => void; + setResultOn: (v: boolean) => void; + setPhase: (v: 'idle' | 'shrink' | 'stack' | 'done') => void; + setShowDouble: (v: boolean) => void; + setShowExtras: (v: boolean) => void; +}; + +export const useCombinationMotion = ({ + inputRef, + styleProbeRef, + targetRef, + setCenterText, + setMode, + setResultOn, + setPhase, + setShowDouble, + setShowExtras, +}: { + inputRef: React.RefObject; + styleProbeRef: React.RefObject; + targetRef: React.RefObject; +} & Setters) => { + const timeoutsRef = useRef([]); + const floatingRef = useRef(null); + const dropAnimRef = useRef(null); + const mountedRef = useRef(true); + + const pushTimeout = useCallback((id: number) => { + timeoutsRef.current.push(id); + return id; + }, []); + + const safeSetTimeout = useCallback( + (fn: () => void, ms: number) => { + const id = window.setTimeout(() => { + if (!mountedRef.current) return; + fn(); + }, ms); + return pushTimeout(id); + }, + [pushTimeout] + ); + + const clearAll = useCallback(() => { + timeoutsRef.current.forEach((id) => window.clearTimeout(id)); + timeoutsRef.current = []; + dropAnimRef.current?.cancel(); + dropAnimRef.current = null; + + if (floatingRef.current) { + try { + floatingRef.current.remove(); + } catch { + // ignore + } + floatingRef.current = null; + } + }, []); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + clearAll(); + }; + }, [clearAll]); + + const scheduleExtras = useCallback(() => { + const extrasDelay = Math.round(M.LIFT_DELAY + M.LIFT_DURATION * M.EXTRAS_AT_LIFT_PROGRESS); + safeSetTimeout(() => setShowExtras(true), extrasDelay); + }, [safeSetTimeout, setShowExtras]); + + const start = useCallback( + (text: string) => { + clearAll(); + + const startEl = inputRef.current; + const targetRect = targetRef.current?.getBoundingClientRect(); + const targetLeft = targetRect + ? targetRect.left + targetRect.width / 2 - M.INNER_W / 2 + : window.innerWidth / 2 - M.INNER_W / 2; + const targetTop = targetRect + ? targetRect.top + targetRect.height / 2 - M.INNER_H / 2 + : M.HEADER_H + (window.innerHeight - M.HEADER_H) / 2 - M.INNER_H / 2; + + if (!startEl) { + setCenterText(text); + setMode('result'); + setResultOn(true); + setPhase('done'); + setShowDouble(true); + scheduleExtras(); + return; + } + + const startRect = startEl.getBoundingClientRect(); + const startLeft = startRect.left + startRect.width / 2 - M.INNER_W / 2; + const startTop = startRect.top + startRect.height / 2 - M.INNER_H / 2; + + const floating = createFloating({ + text, + startLeft, + startTop, + width: M.INNER_W, + height: M.INNER_H, + padding: 20, + }); + + floatingRef.current = floating; + copyComputedStyle(floating, styleProbeRef.current); + + const dx = targetLeft - startLeft; + const dy = targetTop - startTop; + + dropAnimRef.current = animateDrop({ + el: floating, + dx, + dy, + duration: M.DROP_DURATION, + easing: M.DROP_EASING, + }); + + safeSetTimeout(() => { + setCenterText(text); + setMode('result'); + setResultOn(false); + setPhase('idle'); + setShowDouble(false); + setShowExtras(false); + + requestAnimationFrame(() => { + if (!mountedRef.current) return; + + setResultOn(true); + setPhase('shrink'); + safeSetTimeout(() => setPhase('stack'), M.T_SHRINK); + safeSetTimeout(() => setShowDouble(true), M.T_SHRINK + M.DOUBLE_DELAY); + + safeSetTimeout( + () => { + setPhase('done'); + scheduleExtras(); + }, + M.T_SHRINK + Math.max(M.T_STACK, 520) + ); + }); + fadeOutAndRemove(floating, 180); + floatingRef.current = floating; + }, M.DROP_DURATION); + }, + [ + clearAll, + inputRef, + styleProbeRef, + targetRef, + setCenterText, + setMode, + setResultOn, + setPhase, + setShowDouble, + setShowExtras, + scheduleExtras, + safeSetTimeout, + ] + ); + + return { start, cancelMotion: clearAll }; +}; diff --git a/src/hooks/useCombinationNameInput.ts b/src/hooks/useCombinationNameInput.ts new file mode 100644 index 00000000..9384b6f1 --- /dev/null +++ b/src/hooks/useCombinationNameInput.ts @@ -0,0 +1,118 @@ +import { useEffect, useMemo, useState } from 'react'; + +type UseCombinationNameInputParams = { + inputRef: React.RefObject; + existingNames?: string[]; + maxLen?: number; +}; + + +type ErrorType = 'invalidChar' | 'tooLong' | 'onlySpace' | 'duplicate' | 'vowelJamo' | null; + +const DEFAULT_MAX_LEN = 20; +const ALLOWED_REGEX = /^[๊ฐ€-ํžฃใ„ฑ-ใ…Ža-zA-Z0-9 ]*$/; +const VOWEL_JAMO_REGEX = /[\u314F-\u3163\u1161-\u1175]/; +const DISALLOWED_GLOBAL = /[^๊ฐ€-ํžฃใ„ฑ-ใ…Ža-zA-Z0-9 ]/g; + +export const useCombinationNameInput = ({ + inputRef, + existingNames = [], + maxLen = DEFAULT_MAX_LEN, +}: UseCombinationNameInputParams) => { + const [value, setValue] = useState(''); + const [error, setError] = useState(null); + const [isComposing, setIsComposing] = useState(false); + + useEffect(() => { + inputRef.current?.focus(); + }, [inputRef]); + + const normalizedExisting = useMemo(() => existingNames.map((v) => v.trim()), [existingNames]); + + const validate = (next: string): ErrorType => { + if (VOWEL_JAMO_REGEX.test(next)) return 'vowelJamo'; + if (!ALLOWED_REGEX.test(next)) return 'invalidChar'; + if (next.length > maxLen) return 'tooLong'; + if (next.trim().length === 0) return 'onlySpace'; + if (normalizedExisting.includes(next.trim())) return 'duplicate'; + return null; + }; + + const sanitize = (raw: string) => { + const hadVowelJamo = VOWEL_JAMO_REGEX.test(raw); + const removedInvalid = raw.replace(DISALLOWED_GLOBAL, ''); + const sliced = removedInvalid.slice(0, maxLen); + return { + sanitized: sliced, + hadInvalid: removedInvalid !== raw, + wasTooLong: removedInvalid.length > maxLen, + hadVowelJamo, + }; + }; + + const apply = (raw: string) => { + const { sanitized, hadInvalid, wasTooLong, hadVowelJamo } = sanitize(raw); + setValue(sanitized); + + if (hadVowelJamo) { + setError('vowelJamo'); + return; + } + if (hadInvalid) { + setError('invalidChar'); + return; + } + if (wasTooLong) { + setError('tooLong'); + return; + } + setError(validate(sanitized)); + }; + + const onChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + if (isComposing) { + setValue(raw); + return; + } + apply(raw); + }; + + const onCompositionStart = () => setIsComposing(true); + + const onCompositionEnd = (e: React.CompositionEvent) => { + setIsComposing(false); + apply(e.currentTarget.value); + }; + + const isValid = useMemo(() => validate(value) === null, [value]); + + const errorMessage = useMemo(() => { + switch (error) { + case 'vowelJamo': + return '๋‹จ์ผ ๋ชจ์Œ(ใ…, ใ…“, ใ…— โ€ฆ)์€ ์ž…๋ ฅํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'; + case 'invalidChar': + return 'ํŠน์ˆ˜๋ฌธ์ž๋‚˜ ์ด๋ชจ์ง€๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'; + case 'tooLong': + return `์กฐํ•ฉ๋ช…์€ ์ตœ๋Œ€ ${maxLen}์ž๊นŒ์ง€ ์ž…๋ ฅ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.`; + case 'onlySpace': + return '์กฐํ•ฉ๋ช…์„ ํ•œ ๊ธ€์ž ์ด์ƒ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'; + case 'duplicate': + return '์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์กฐํ•ฉ๋ช…์ž…๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์ด๋ฆ„์„ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'; + default: + return null; + } + }, [error, maxLen]); + + return { + value, + onChange, + onCompositionStart, + onCompositionEnd, + error, + errorMessage, + isValid, + validate, + setValue, + }; +}; diff --git a/src/hooks/useCombinationSort.ts b/src/hooks/useCombinationSort.ts new file mode 100644 index 00000000..5e574c3e --- /dev/null +++ b/src/hooks/useCombinationSort.ts @@ -0,0 +1,53 @@ +import { useState, useMemo, useCallback } from 'react'; +import type { ComboListItem } from '@/types/combo/combo'; + +type SortOption = 'latest' | 'oldest' | 'alphabetical'; + +interface UseCombinationSortReturn { + sortOption: string; + setSortOption: (option: string) => void; + sortedCombos: ComboListItem[]; +} + +export const useCombinationSort = (combos: ComboListItem[]): UseCombinationSortReturn => { + const [sortOption, setSortOptionInternal] = useState('latest'); + + const setSortOption = useCallback((option: string) => { + setSortOptionInternal(option as SortOption); + }, []); + + // ์ •๋ ฌ๋œ ์กฐํ•ฉ ๋ชฉ๋ก + const sortedCombos = useMemo(() => { + const pinnedCombos = combos.filter(c => c.isPinned); + const unpinnedCombos = combos.filter(c => !c.isPinned); + + // ์ฆ๊ฒจ์ฐพ๊ธฐ๋Š” pinnedAt ๋‚ด๋ฆผ์ฐจ์ˆœ (์ตœ๊ทผ ์ฆ๊ฒจ์ฐพ๊ธฐํ•œ ๊ฒƒ์ด ์œ„๋กœ) + pinnedCombos.sort((a, b) => { + const aTime = new Date(a.pinnedAt || 0).getTime(); + const bTime = new Date(b.pinnedAt || 0).getTime(); + return bTime - aTime; + }); + + // ์ผ๋ฐ˜ ์กฐํ•ฉ์€ sortOption์— ๋”ฐ๋ผ ์ •๋ ฌ + const sortUnpinned = (arr: ComboListItem[]) => { + switch (sortOption) { + case 'latest': + return arr.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + case 'oldest': + return arr.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + case 'alphabetical': + return arr.sort((a, b) => a.comboName.localeCompare(b.comboName, 'ko')); + default: + return arr; + } + }; + + return [...pinnedCombos, ...sortUnpinned(unpinnedCombos)]; + }, [combos, sortOption]); + + return { + sortOption, + setSortOption, + sortedCombos, + }; +}; diff --git a/src/hooks/useCrossfadeImage.ts b/src/hooks/useCrossfadeImage.ts new file mode 100644 index 00000000..73cf2c03 --- /dev/null +++ b/src/hooks/useCrossfadeImage.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from 'react'; + +type Options = { + transitionMs: number; +}; + +export const useCrossfadeImage = (src: string, { transitionMs }: Options) => { + const [currentSrc, setCurrentSrc] = useState(src); + const [nextSrc, setNextSrc] = useState(null); + const [isNextVisible, setIsNextVisible] = useState(false); + + const transitionTimerRef = useRef(null); + const loadSeqRef = useRef(0); + + useEffect(() => { + if (src === currentSrc) return; + + if (transitionTimerRef.current) { + window.clearTimeout(transitionTimerRef.current); + transitionTimerRef.current = null; + } + + const seq = ++loadSeqRef.current; + + const img = new Image(); + img.src = src; + + const start = () => { + if (loadSeqRef.current !== seq) return; + + setNextSrc(src); + setIsNextVisible(false); + + requestAnimationFrame(() => { + if (loadSeqRef.current !== seq) return; + setIsNextVisible(true); + }); + + transitionTimerRef.current = window.setTimeout(() => { + if (loadSeqRef.current !== seq) return; + setCurrentSrc(src); + setNextSrc(null); + setIsNextVisible(false); + }, transitionMs); + }; + + if (img.complete) start(); + else img.onload = start; + + return () => { + if (transitionTimerRef.current) { + window.clearTimeout(transitionTimerRef.current); + transitionTimerRef.current = null; + } + }; + }, [src, currentSrc, transitionMs]); + + return { currentSrc, nextSrc, isNextVisible }; +}; diff --git a/src/hooks/useDeviceSearch.ts b/src/hooks/useDeviceSearch.ts new file mode 100644 index 00000000..675d65eb --- /dev/null +++ b/src/hooks/useDeviceSearch.ts @@ -0,0 +1,133 @@ +import { useState, useEffect, useMemo } from 'react'; +import type { FilterOption } from '@/constants/devices'; +import { getCategoryDeviceType, getSortType } from '@/constants/deviceMapping'; +import { useSearchDevices } from '@/apis/devices/searchDevices'; +import { useGetBrands } from '@/apis/devices/getBrands'; +import { useIntersectionObserver } from '@/hooks/useIntersectionObserver'; + +export const useDeviceSearch = () => { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(null); + const [sortOption, setSortOption] = useState('latest'); + const [selectedPrice, setSelectedPrice] = useState([]); + const [selectedBrand, setSelectedBrand] = useState(null); + + // ๋ธŒ๋žœ๋“œ API ์กฐํšŒ - ์„ ํƒ๋œ ์นดํ…Œ๊ณ ๋ฆฌ์— ๋”ฐ๋ผ deviceType ์ „๋‹ฌ + const { data: brandsData } = useGetBrands(getCategoryDeviceType(selectedCategory)); + + // API ๋ฐ์ดํ„ฐ๋ฅผ FilterOption ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ + const brandOptions: FilterOption[] = useMemo(() => { + if (!brandsData?.result) return []; + return brandsData.result.map(brand => ({ + value: brand.brandId.toString(), + label: brand.brandName, + })); + }, [brandsData]); + + // ๊ธฐ๊ธฐ ๊ฒ€์ƒ‰ API ํŒŒ๋ผ๋ฏธํ„ฐ ๊ตฌ์„ฑ + const apiSearchParams = useMemo(() => { + const deviceType = getCategoryDeviceType(selectedCategory); + + // ๊ฐ€๊ฒฉ๋Œ€ ํ•„ํ„ฐ๋ฅผ minPrice/maxPrice๋กœ ๋ณ€ํ™˜ + let minPrice: number | undefined = undefined; + let maxPrice: number | undefined = undefined; + + if (selectedPrice.length > 0) { + const prices = selectedPrice.map(value => { + switch (value) { + case 'under-100': + return { min: 0, max: 1000000 }; + case '100-150': + return { min: 1000000, max: 1500000 }; + case '150-200': + return { min: 1500000, max: 2000000 }; + case 'over-200': + return { min: 2000000, max: Infinity }; + default: + return { min: 0, max: Infinity }; + } + }); + + // ์„ ํƒ๋œ ๋ชจ๋“  ๊ฐ€๊ฒฉ๋Œ€์—์„œ ์ตœ์†Œ๊ฐ’๊ณผ ์ตœ๋Œ€๊ฐ’ ๊ณ„์‚ฐ + const calculatedMin = Math.min(...prices.map(p => p.min)); + const calculatedMax = Math.max(...prices.map(p => p.max)); + + minPrice = calculatedMin > 0 ? calculatedMin : undefined; + maxPrice = calculatedMax < Infinity ? calculatedMax : undefined; + } + + // ๊ฐ€๊ฒฉ ์ •๋ ฌ์€ ํด๋ผ์ด์–ธํŠธ์—์„œ ์ฒ˜๋ฆฌํ•˜๋ฏ€๋กœ API์—๋Š” ๊ธฐ๋ณธ ์ •๋ ฌ ์‚ฌ์šฉ + const apiSortType = (sortOption === 'price-low' || sortOption === 'price-high') + ? 'LATEST' + : getSortType(sortOption); + + return { + keyword: searchQuery || undefined, + size: 24, + sortType: apiSortType, + deviceTypes: deviceType ? [deviceType] : undefined, + brandIds: selectedBrand ? [Number(selectedBrand)] : undefined, + minPrice, + maxPrice, + }; + }, [searchQuery, selectedCategory, sortOption, selectedBrand, selectedPrice]); + + // ๊ธฐ๊ธฐ ๊ฒ€์ƒ‰ API ํ˜ธ์ถœ + const { + data: searchData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: isSearchLoading, + isError: isSearchError, + } = useSearchDevices(apiSearchParams); + + // ์ „์ฒด ๊ธฐ๊ธฐ ๋ชฉ๋ก (๋ชจ๋“  ํŽ˜์ด์ง€ ๊ฒฐํ•ฉ + ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ ์ •๋ ฌ) + const allDevices = useMemo(() => { + const devices = searchData?.pages.flatMap(page => page.devices) ?? []; + + // ๊ฐ€๊ฒฉ ์ •๋ ฌ์€ ํด๋ผ์ด์–ธํŠธ์—์„œ ์ฒ˜๋ฆฌ (๋ฐฑ์—”๋“œ ๋ฏธ์ง€์› ์‹œ) + if (sortOption === 'price-low') { + return [...devices].sort((a, b) => a.price - b.price); + } else if (sortOption === 'price-high') { + return [...devices].sort((a, b) => b.price - a.price); + } + + return devices; + }, [searchData, sortOption]); + + // ๋ฌดํ•œ ์Šคํฌ๋กค ํŠธ๋ฆฌ๊ฑฐ + const { targetRef, isIntersecting } = useIntersectionObserver({ rootMargin: '100px' }); + + // ์Šคํฌ๋กค ๊ฐ์ง€ ์‹œ ๋‹ค์Œ ํŽ˜์ด์ง€ ๋กœ๋“œ + useEffect(() => { + if (isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage]); + + // ์นดํ…Œ๊ณ ๋ฆฌ ๋ณ€๊ฒฝ ์‹œ ์„ ํƒ๋œ ๋ธŒ๋žœ๋“œ ์ดˆ๊ธฐํ™” + useEffect(() => { + setSelectedBrand(null); + }, [selectedCategory]); + + return { + searchQuery, + setSearchQuery, + selectedCategory, + setSelectedCategory, + sortOption, + setSortOption, + selectedPrice, + setSelectedPrice, + selectedBrand, + setSelectedBrand, + brandOptions, + allDevices, + isSearchLoading, + isSearchError, + isFetchingNextPage, + hasNextPage, + targetRef, + }; +}; diff --git a/src/hooks/useDeviceSelection.ts b/src/hooks/useDeviceSelection.ts new file mode 100644 index 00000000..87fa98a0 --- /dev/null +++ b/src/hooks/useDeviceSelection.ts @@ -0,0 +1,40 @@ +import { useState } from 'react'; + +export interface UseDeviceSelectionReturn { + selectedDevices: number[]; + handleSelectAll: (deviceIds: number[]) => void; + handleSelectDevice: (deviceId: number) => void; + clearSelection: () => void; +} + +export const useDeviceSelection = (): UseDeviceSelectionReturn => { + const [selectedDevices, setSelectedDevices] = useState([]); + + // ์ „์ฒด ์„ ํƒ/ํ•ด์ œ ํ•ธ๋“ค๋Ÿฌ + const handleSelectAll = (deviceIds: number[]) => { + if (selectedDevices.length === deviceIds.length) { + setSelectedDevices([]); + } else { + setSelectedDevices(deviceIds); + } + }; + + // ๊ฐœ๋ณ„ ์„ ํƒ/ํ•ด์ œ ํ•ธ๋“ค๋Ÿฌ + const handleSelectDevice = (deviceId: number) => { + setSelectedDevices((prev) => + prev.includes(deviceId) ? prev.filter((id) => id !== deviceId) : [...prev, deviceId] + ); + }; + + // ์„ ํƒ ์ดˆ๊ธฐํ™” + const clearSelection = () => { + setSelectedDevices([]); + }; + + return { + selectedDevices, + handleSelectAll, + handleSelectDevice, + clearSelection, + }; +}; diff --git a/src/hooks/useGroupedTags.ts b/src/hooks/useGroupedTags.ts new file mode 100644 index 00000000..fc516b7f --- /dev/null +++ b/src/hooks/useGroupedTags.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; +import { useGetTags } from '@/apis/tag/getTags'; +import { splitTags, type TagGroups } from '@/utils/splitTags'; + +/** + * ํƒœ๊ทธ ๋ชฉ๋ก ์กฐํšŒ ๋ฐ ์˜จ๋ณด๋”ฉ ๊ทธ๋ฃน๋ณ„ ๋ถ„๋ฅ˜ ํ›… + * - API ํ˜ธ์ถœ: ์ „์ฒด ํƒœ๊ทธ ๋ชฉ๋ก ์กฐํšŒ + * - ์ž๋™ ๋ถ„๋ฅ˜: interest, lifestyle, brand, unknown ๊ทธ๋ฃน์œผ๋กœ ๋ถ„๋ฅ˜ + * - ๋กœ๋”ฉ/์—๋Ÿฌ ์ƒํƒœ ์ œ๊ณต + */ +export const useGroupedTags = () => { + const { data: tags, isLoading, error } = useGetTags(); + + const groupedTags: TagGroups = useMemo(() => { + if (!tags || tags.length === 0) { + return { + interest: [], + lifestyle: [], + brand: [], + unknown: [], + }; + } + return splitTags(tags); + }, [tags]); + + return { + tags: groupedTags, + isLoading, + error, + }; +}; diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts new file mode 100644 index 00000000..b2c78c02 --- /dev/null +++ b/src/hooks/useIntersectionObserver.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState } from 'react'; + +interface UseIntersectionObserverOptions { + root?: Element | null; + rootMargin?: string; + threshold?: number | number[]; +} + +export const useIntersectionObserver = ( + options: UseIntersectionObserverOptions = {} +) => { + const [isIntersecting, setIsIntersecting] = useState(false); + const targetRef = useRef(null); + + useEffect(() => { + const target = targetRef.current; + if (!target) return; + + const observer = new IntersectionObserver( + ([entry]) => { + setIsIntersecting(entry.isIntersecting); + }, + { + root: options.root ?? null, + rootMargin: options.rootMargin ?? '0px', + threshold: options.threshold ?? 0, + } + ); + + observer.observe(target); + + return () => { + observer.disconnect(); + }; + }, [options.root, options.rootMargin, options.threshold]); + + return { targetRef, isIntersecting }; +}; diff --git a/src/hooks/useInterval.ts b/src/hooks/useInterval.ts new file mode 100644 index 00000000..390a110e --- /dev/null +++ b/src/hooks/useInterval.ts @@ -0,0 +1,23 @@ +import { useEffect, useRef } from 'react'; + +const useInterval = (callback: () => void, delay: number | null) => { + const savedCallback = useRef(callback); + + // ํ•ญ์ƒ ์ตœ์‹  callback ์œ ์ง€ + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + // interval ์„ค์ •/ํ•ด์ œ + useEffect(() => { + if (delay === null) return; + + const id = window.setInterval(() => { + savedCallback.current(); + }, delay); + + return () => window.clearInterval(id); + }, [delay]); +}; + +export default useInterval; diff --git a/src/hooks/useLogin.ts b/src/hooks/useLogin.ts new file mode 100644 index 00000000..1c41202f --- /dev/null +++ b/src/hooks/useLogin.ts @@ -0,0 +1,34 @@ +import { usePostLogin } from '@/apis/auth/postLogin'; +import { useQueryClient } from '@tanstack/react-query'; +import { finalizeLogin } from '@/utils/finalizeLogin'; +import type { LoginRequest } from '@/types/auth/login'; + +/** + * ๋กœ๊ทธ์ธ ํ”Œ๋กœ์šฐ ํ›… + * - ๋กœ๊ทธ์ธ API ํ˜ธ์ถœ + * - finalizeLogin ์‹คํ–‰ (ํ† ํฐ ์ €์žฅ + ์œ ์ € ์ •๋ณด ์บ์‹œ) + * - pending ์ƒํƒœ ์ œ๊ณต + */ +export const useLogin = () => { + const queryClient = useQueryClient(); + const { mutateAsync: postLogin, isPending } = usePostLogin(); + + const loginAndFinalize = async (credentials: LoginRequest): Promise => { + const res = await postLogin(credentials); + + if (!res.result) { + throw new Error('๋กœ๊ทธ์ธ ์‘๋‹ต์— ํ† ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค.'); + } + + await finalizeLogin( + res.result.accessToken, + queryClient, + credentials.keepLogin ?? false, + ); + }; + + return { + loginAndFinalize, + isPending, + }; +}; diff --git a/src/hooks/useLogout.ts b/src/hooks/useLogout.ts new file mode 100644 index 00000000..db0be864 --- /dev/null +++ b/src/hooks/useLogout.ts @@ -0,0 +1,41 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { usePostLogout } from '@/apis/auth/postLogout'; +import { finalizeLogout } from '@/utils/finalizeLogout'; +import { useAuth } from '@/hooks/useAuth'; +import { ROUTES } from '@/constants/routes'; +import { useNavigate } from 'react-router-dom'; + +/** + * ๋กœ๊ทธ์•„์›ƒ ํ›… + + - ๋กœ๊ทธ์•„์›ƒ API ํ˜ธ์ถœ๊ณผ ๋กœ๊ทธ์•„์›ƒ ํ›„์ฒ˜๋ฆฌ๋ฅผ ๋ชจ๋‘ ํฌํ•จ + - ๋น„ํšŒ์›(ํ† ํฐ ์—†์Œ) ์ผ€์ด์Šค๋„ ์ฒ˜๋ฆฌ: ๋กœ๊ทธ์•„์›ƒ API ํ˜ธ์ถœ ์—†์ด ํ›„์ฒ˜๋ฆฌ๋งŒ ์ˆ˜ํ–‰ + - ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ + */ +export const useLogout = () => { + const queryClient = useQueryClient(); + const { mutateAsync: postLogout, isPending } = usePostLogout(); + const navigate = useNavigate(); + const { hasToken } = useAuth(); + + const logout = async () => { + try { + // ํ† ํฐ์ด ์žˆ์œผ๋ฉด API ํ˜ธ์ถœ + if (hasToken) { + await postLogout(); + } + } catch (error) { + // API ํ˜ธ์ถœ ์‹คํŒจํ•ด๋„ ๋กœ๊ทธ์•„์›ƒ์€ ์ง„ํ–‰ (๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜ ๋“ฑ) + // ์—๋Ÿฌ๋Š” ๋ฌด์‹œํ•˜๊ณ  ํ›„์ฒ˜๋ฆฌ ์ง„ํ–‰ + } finally { + // API ํ˜ธ์ถœ ์„ฑ๊ณต/์‹คํŒจ์™€ ๊ด€๊ณ„์—†์ด ํ•ญ์ƒ ํ›„์ฒ˜๋ฆฌ ์ˆ˜ํ–‰ + finalizeLogout(queryClient); + navigate(ROUTES.auth.login); + } + }; + + return { + logout, + isPending, + }; +}; diff --git a/src/hooks/useModalScrollLock.ts b/src/hooks/useModalScrollLock.ts new file mode 100644 index 00000000..7f832201 --- /dev/null +++ b/src/hooks/useModalScrollLock.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; + +/** + * ๋ชจ๋‹ฌ์ด ์—ด๋ฆด ๋•Œ ๋ฐฐ๊ฒฝ ์Šคํฌ๋กค์„ ๋ฐฉ์ง€ํ•˜๋Š” ํ›… + * @param isOpen - ๋ชจ๋‹ฌ์ด ์—ด๋ ค์žˆ๋Š”์ง€ ์—ฌ๋ถ€ + */ +export const useModalScrollLock = (isOpen: boolean) => { + useEffect(() => { + if (isOpen) { + // ํ˜„์žฌ ์Šคํฌ๋กค ์œ„์น˜ ์ €์žฅ + const scrollY = window.scrollY; + + // body ๊ณ ์ • (์Šคํฌ๋กค ๋ฐฉ์ง€) + document.body.style.position = 'fixed'; + document.body.style.top = `-${scrollY}px`; + document.body.style.width = '100%'; + document.body.style.overflow = 'hidden'; + } else { + // ์ €์žฅ๋œ ์Šคํฌ๋กค ์œ„์น˜ ๋ณต์› + const scrollY = document.body.style.top; + document.body.style.position = ''; + document.body.style.top = ''; + document.body.style.width = ''; + document.body.style.overflow = ''; + + // ์Šคํฌ๋กค ์œ„์น˜๋กœ ์ด๋™ + if (scrollY) { + window.scrollTo(0, parseInt(scrollY || '0') * -1); + } + } + + // cleanup: ์ปดํฌ๋„ŒํŠธ ์–ธ๋งˆ์šดํŠธ ์‹œ ์›๋ž˜๋Œ€๋กœ ๋ณต๊ตฌ + return () => { + const scrollY = document.body.style.top; + document.body.style.position = ''; + document.body.style.top = ''; + document.body.style.width = ''; + document.body.style.overflow = ''; + if (scrollY) { + window.scrollTo(0, parseInt(scrollY || '0') * -1); + } + }; + }, [isOpen]); +}; diff --git a/src/hooks/useMyPageScroll.ts b/src/hooks/useMyPageScroll.ts new file mode 100644 index 00000000..1c65191a --- /dev/null +++ b/src/hooks/useMyPageScroll.ts @@ -0,0 +1,42 @@ +import { useState, useEffect, type RefObject } from 'react'; + +interface UseMyPageScrollReturn { + isAtBottom: boolean; + showTopButton: boolean; +} + +/** + * MyPage ์ „์šฉ ์Šคํฌ๋กค ์ƒํƒœ ๊ด€๋ฆฌ ํ›… + * @param combinationListRef - ์กฐํ•ฉ ๋ชฉ๋ก ref + * @returns isAtBottom - ํ•˜๋‹จ ๋„๋‹ฌ ์—ฌ๋ถ€, showTopButton - Top ๋ฒ„ํŠผ ํ‘œ์‹œ ์—ฌ๋ถ€ + */ +export const useMyPageScroll = ( + combinationListRef: RefObject +): UseMyPageScrollReturn => { + const [isAtBottom, setIsAtBottom] = useState(false); + const [showTopButton, setShowTopButton] = useState(false); + + useEffect(() => { + const handleScroll = () => { + const scrollTop = window.scrollY; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + // ํ•˜๋‹จ ๋„๋‹ฌ ์—ฌ๋ถ€ (๊ทธ๋ผ๋ฐ์ด์…˜์šฉ) + setIsAtBottom(scrollTop + windowHeight >= documentHeight - 50); + + // ์กฐํ•ฉ 3๊ฐœ ์ •๋„ ์Šคํฌ๋กค ์‹œ Top ๋ฒ„ํŠผ ํ‘œ์‹œ (์•ฝ 800px) + if (combinationListRef.current) { + const listTop = combinationListRef.current.offsetTop; + const thirdCombinationVisible = scrollTop + windowHeight >= listTop + 800; + setShowTopButton(thirdCombinationVisible); + } + }; + + window.addEventListener('scroll', handleScroll); + handleScroll(); + return () => window.removeEventListener('scroll', handleScroll); + }, [combinationListRef]); + + return { isAtBottom, showTopButton }; +}; diff --git a/src/hooks/useOnboardingNavigation.ts b/src/hooks/useOnboardingNavigation.ts new file mode 100644 index 00000000..7865285b --- /dev/null +++ b/src/hooks/useOnboardingNavigation.ts @@ -0,0 +1,44 @@ +import { useNavigate } from 'react-router-dom'; +import { ROUTES } from '@/constants/routes'; +import { useAuth } from '@/hooks/useAuth'; + +/** + * ์˜จ๋ณด๋”ฉ ํ”Œ๋กœ์šฐ์˜ Step ๋„ค๋น„๊ฒŒ์ด์…˜์„ ์œ„ํ•œ ์ปค์Šคํ…€ ํ›… + * StepIndicator์—์„œ ์ด์ „ step ํด๋ฆญ ์‹œ ํ•ด๋‹น ํŽ˜์ด์ง€๋กœ ์ด๋™ + * ์ด์ „ ๋‹จ๊ณ„๋กœ ์ด๋™ ์‹œ replace: true๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํžˆ์Šคํ† ๋ฆฌ ์Šคํƒ ๊ด€๋ฆฌ + * + * ๋กœ๊ทธ์ธ๋œ ์ƒํƒœ์—์„œ๋Š” ํšŒ์›๊ฐ€์ž… ํŽ˜์ด์ง€(step 1, 2)๋กœ ์ด๋™ํ•˜์ง€ ์•Š์Œ + */ +export const useOnboardingNavigation = () => { + const navigate = useNavigate(); + const { isLoggedIn } = useAuth(); + + const handleStepClick = (step: number) => { + // ๋กœ๊ทธ์ธ๋œ ์ƒํƒœ์—์„œ ํšŒ์›๊ฐ€์ž… ํŽ˜์ด์ง€(step 1, 2)๋กœ ์ด๋™ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ๋ฌด์‹œ + if (isLoggedIn && (step === 1 || step === 2)) { + return; + } + + // ์ด์ „ ๋‹จ๊ณ„๋กœ ์ด๋™ ์‹œ ํžˆ์Šคํ† ๋ฆฌ๋ฅผ ๊ต์ฒดํ•˜์—ฌ ๋’ค๋กœ๊ฐ€๊ธฐ ๋ฌธ์ œ ๋ฐฉ์ง€ + const options = { replace: true }; + + switch (step) { + case 1: + navigate(ROUTES.auth.signup.account, options); + break; + case 2: + navigate(ROUTES.auth.signup.profile, options); + break; + case 3: + navigate(ROUTES.onboarding.lifestyle, options); + break; + case 4: + navigate(ROUTES.onboarding.combination, options); + break; + default: + break; + } + }; + + return { handleStepClick }; +}; diff --git a/src/hooks/usePollComboEvaluation.ts b/src/hooks/usePollComboEvaluation.ts new file mode 100644 index 00000000..9d713799 --- /dev/null +++ b/src/hooks/usePollComboEvaluation.ts @@ -0,0 +1,101 @@ +import { useCallback, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { axiosInstance } from '@/apis/axios/axios'; +import { queryKey } from '@/constants/queryKey'; +import type { GetComboEvaluationResponse, ComboEvaluationResult } from '@/types/combo/evaluation'; +import axios from 'axios'; + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// ์กฐํ•ฉ ํ‰๊ฐ€ ํด๋ง ํ›… +// ์กฐํ•ฉ์ด ๋ณ€๊ฒฝ๋œ ์งํ›„ ํ‰๊ฐ€ ๊ณ„์‚ฐ์ด ๋๋‚  ๋•Œ๊นŒ์ง€ +// 1์ดˆ ๊ฐ„๊ฒฉ์œผ๋กœ polling โ†’ ์™„๋ฃŒ ์‹œ React Query ์บ์‹œ ๊ฐฑ์‹  +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const POLL_INTERVAL = 1000; // 1์ดˆ +const MAX_RETRIES = 5; + +// 404 + EVAL_4041 ์ฝ”๋“œ์ธ์ง€ ํŒ๋ณ„ +const isPendingError = (error: unknown): boolean => { + if (!axios.isAxiosError(error)) return false; + if (error.response?.status !== 404) return false; + return error.response.data?.code === 'EVAL_4041'; +}; + +export type PollResult = + | { status: 'success'; data: ComboEvaluationResult } + | { status: 'timeout' } + | { status: 'error'; error: unknown }; + +export const usePollComboEvaluation = () => { + const queryClient = useQueryClient(); + const abortRef = useRef(null); + + const poll = useCallback( + (comboId: number): Promise => { + // ์ด์ „ ํด๋ง์ด ์ง„ํ–‰ ์ค‘์ด๋ฉด ์ทจ์†Œ + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + return new Promise((resolve) => { + let retryCount = 0; + + const attempt = async () => { + // ์ทจ์†Œ๋œ ๊ฒฝ์šฐ ์ฆ‰์‹œ ์ข…๋ฃŒ + if (controller.signal.aborted) { + resolve({ status: 'error', error: new Error('Polling aborted') }); + return; + } + + try { + const { data } = await axiosInstance.get( + `/api/combos/${comboId}/evaluation`, + { signal: controller.signal } + ); + + // ์„ฑ๊ณต(200): ์บ์‹œ ๊ฐฑ์‹  ํ›„ ์™„๋ฃŒ + const result = data.result!; + queryClient.setQueryData( + [queryKey.COMBO_EVALUATION, comboId], + result + ); + resolve({ status: 'success', data: result }); + } catch (error) { + // ์ทจ์†Œ๋œ ๊ฒฝ์šฐ + if (controller.signal.aborted) { + resolve({ status: 'error', error: new Error('Polling aborted') }); + return; + } + + // 404 + EVAL_4041: ์•„์ง ๊ณ„์‚ฐ ์ค‘ โ†’ ๋‹ค์Œ ํ„ด ๋Œ€๊ธฐ + if (isPendingError(error)) { + retryCount += 1; + + if (retryCount >= MAX_RETRIES) { + resolve({ status: 'timeout' }); + return; + } + + setTimeout(attempt, POLL_INTERVAL); + return; + } + + // ๊ทธ ์™ธ ์—๋Ÿฌ: ์ฆ‰์‹œ ์ข…๋ฃŒ + resolve({ status: 'error', error }); + } + }; + + attempt(); + }); + }, + [queryClient] + ); + + // ์ง„ํ–‰ ์ค‘์ธ ํด๋ง ์ทจ์†Œ + const cancel = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + }, []); + + return { poll, cancel }; +}; diff --git a/src/hooks/useScrollState.ts b/src/hooks/useScrollState.ts new file mode 100644 index 00000000..eebde81f --- /dev/null +++ b/src/hooks/useScrollState.ts @@ -0,0 +1,48 @@ +import { useState, useEffect, useCallback, type RefObject } from 'react'; +import { SCROLL_CONSTANTS } from '@/constants/devices'; + +export const useScrollState = (productGridRef: RefObject) => { + const [isAtBottom, setIsAtBottom] = useState(false); + const [showTopButton, setShowTopButton] = useState(false); + + // ํŽ˜์ด์ง€ ๋งˆ์šดํŠธ ์‹œ ์ƒ๋‹จ์œผ๋กœ ์Šคํฌ๋กค + useEffect(() => { + window.scrollTo(0, 0); + }, []); + + const handleScroll = useCallback(() => { + const scrollTop = window.scrollY; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + /* ๋งจ ๋งˆ์ง€๋ง‰ ์Šคํฌ๋กค ๋„๋‹ฌ ์—ฌ๋ถ€ ์ฒดํฌ */ + const reachedBottom = + scrollTop + windowHeight >= documentHeight - SCROLL_CONSTANTS.BOTTOM_BUFFER; + setIsAtBottom(reachedBottom); + + /* 3ํ–‰์ด ์™„์ „ํžˆ ๋ณด์ผ ๋•Œ Top ๋ฒ„ํŠผ ํ‘œ์‹œ */ + if (productGridRef.current) { + const gridTop = productGridRef.current.offsetTop; + const thirdRowVisible = + scrollTop + windowHeight >= gridTop + SCROLL_CONSTANTS.TOP_BUTTON_THRESHOLD; + setShowTopButton(thirdRowVisible); + } + }, [productGridRef]); + + useEffect(() => { + window.addEventListener('scroll', handleScroll); + handleScroll(); + + return () => window.removeEventListener('scroll', handleScroll); + }, [handleScroll]); + + const handleScrollToTop = useCallback(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + + return { + isAtBottom, + showTopButton, + handleScrollToTop, + }; +}; diff --git a/src/hooks/useTimer.ts b/src/hooks/useTimer.ts new file mode 100644 index 00000000..72add474 --- /dev/null +++ b/src/hooks/useTimer.ts @@ -0,0 +1,73 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import useInterval from '@/hooks/useInterval'; + +// ํƒ€์ด๋จธ ์˜ต์…˜ ํƒ€์ž… +type UseTimerOptions = { + /** ์ดˆ๊ธฐ ์‹œ๊ฐ„ (์ดˆ) */ + initialSeconds: number; + /** ํƒ€์ด๋จธ๊ฐ€ ๋งŒ๋ฃŒ๋˜์—ˆ์„ ๋•Œ ํ˜ธ์ถœ๋  ์ฝœ๋ฐฑ */ + onExpire?: () => void; + /** ํƒ€์ด๋จธ๊ฐ€ ์‹คํ–‰ ์ค‘์ธ์ง€ ์—ฌ๋ถ€ */ + enabled?: boolean; +}; + +// ํƒ€์ด๋จธ ๋ฐ˜ํ™˜ ํƒ€์ž… +type UseTimerReturn = { + /** ๋‚จ์€ ์‹œ๊ฐ„ (์ดˆ) */ + timeLeft: number; + /** ํƒ€์ด๋จธ ์‹œ์ž‘ ํ•จ์ˆ˜ (์ฒ˜์Œ ์‹œ์ž‘ยท์žฌ์‹œ์ž‘ ๋ชจ๋‘ ๋™์ผ) */ + start: () => void; + /** ํƒ€์ด๋จธ ์ •์ง€ ํ•จ์ˆ˜ */ + stop: () => void; + /** ํƒ€์ด๋จธ๊ฐ€ ์‹คํ–‰ ์ค‘์ธ์ง€ ์—ฌ๋ถ€ */ + isRunning: boolean; +}; + +// ํƒ€์ด๋จธ ํ›… +const useTimer = ({ initialSeconds, onExpire, enabled = true }: UseTimerOptions): UseTimerReturn => { + const [timeLeft, setTimeLeft] = useState(initialSeconds); + const [isRunning, setIsRunning] = useState(false); + const onExpireRef = useRef(onExpire); + + // onExpire ์ฝœ๋ฐฑ์„ ref์— ์ €์žฅ (์˜์กด์„ฑ ๋ฐฐ์—ด์— ๋„ฃ์ง€ ์•Š์•„๋„ ๋จ) + useEffect(() => { + onExpireRef.current = onExpire; + }, [onExpire]); + + // ๋งค์ดˆ๋งˆ๋‹ค ์‹คํ–‰๋  ์ฝœ๋ฐฑ + const tick = useCallback(() => { + setTimeLeft((prev) => { + if (prev <= 1) { + // ์‹œ๊ฐ„์ด ๋งŒ๋ฃŒ๋˜๋ฉด ํƒ€์ด๋จธ ์ •์ง€ + setIsRunning(false); + // ๋งŒ๋ฃŒ ์ฝœ๋ฐฑ ํ˜ธ์ถœ + onExpireRef.current?.(); + return 0; + } + return prev - 1; + }); + }, []); + + // useInterval๋กœ ๋งค์ดˆ๋งˆ๋‹ค tick ์‹คํ–‰ + // delay๊ฐ€ null์ด๋ฉด interval์ด ๋ฉˆ์ถค + useInterval(tick, enabled && isRunning ? 1000 : null); + + // ํƒ€์ด๋จธ ์‹œ์ž‘ (์ฒ˜์Œ ์‹œ์ž‘ยท์žฌ์‹œ์ž‘ ๋ชจ๋‘ ๋™์ผ) + const start = useCallback(() => { + setTimeLeft(initialSeconds); + setIsRunning(true); + }, [initialSeconds]); + + const stop = useCallback(() => { + setIsRunning(false); + }, []); + + return { + timeLeft, + start, + stop, + isRunning, + }; +}; + +export default useTimer; diff --git a/src/index.css b/src/index.css index e69de29b..50c08859 100644 --- a/src/index.css +++ b/src/index.css @@ -0,0 +1,420 @@ +@import 'tailwindcss'; + +@theme { + /* Main Color */ + --color-blue-100: #eff6ff; + --color-blue-200: #bddaff; + --color-blue-300: #8abdff; + --color-blue-400: #57a0ff; + --color-blue-500: #2484ff; + --color-blue-600: #0069f0; + --color-blue-700: #0053bd; + --color-blue-800: #003c8a; + --color-blue-900: #002657; + + /* gray scale */ + --color-white: #ffffff; + --color-gray-100: #eeeef0; + --color-gray-200: #bababe; + --color-gray-300: #878791; + --color-gray-400: #54545f; + --color-gray-500: #21212d; + --color-black: #21212d; + --color-black-50: rgba(0, 0, 0, 0.5); /* background */ + + /* Warning */ + --color-warning: #e6284b; + + /* sub1 (tag color) */ + --color-light-green: #bdf8e1; + --color-dark-green: #00719f; + --color-light-yellow: #fee8c3; + --color-dark-yellow: #bc5016; + --color-light-purple: #eadaff; + --color-dark-purple: #941e9f; + + /* sub2 (tag status color) */ + --color-optimal: #0069f0; /* ์ตœ์  */ + --color-good: #6f46a4; /* ์–‘ํ˜ธ */ + --color-normal: #009f96; /* ๋ณดํ†ต */ + --color-poor: #e6284b; /* ๋ฏธํก */ + + /* Radius */ + --radius-button: 4px; + --radius-card: 12px; + --radius-tag: 999px; + + /* Spacing */ + --spacing: 1px; + --spacing-8: 8px; + --spacing-12: 12px; + --spacing-16: 16px; + --spacing-20: 20px; + --spacing-24: 24px; + --spacing-28: 28px; + --spacing-32: 32px; + --spacing-36: 36px; + --spacing-40: 40px; + --spacing-44: 44px; + --spacing-56: 56px; + --spacing-60: 60px; + --spacing-68: 68px; + --spacing-72: 72px; + --spacing-76: 76px; + --spacing-80: 80px; + --spacing-84: 84px; + --spacing-88: 88px; + --spacing-96: 96px; + --spacing-100: 100px; + --spacing-104: 104px; + --spacing-108: 108px; + --spacing-116: 116px; + --spacing-120: 120px; + --spacing-124: 124px; + --spacing-132: 132px; + --spacing-146: 146px; + --spacing-156: 156px; + --spacing-160: 160px; + --spacing-164: 164px; + --spacing-184: 184px; + --spacing-196: 196px; + --spacing-204: 204px; + --spacing-228: 228px; + --spacing-268: 268px; + --spacing-280: 280px; + --spacing-300: 300px; + + /* Typography */ + --font-service: 'KIMM_Bold', system-ui, sans-serif; + --font-main: 'WantedSans', system-ui, sans-serif; + + --font-size-48: 48px; + --font-size-40: 40px; + --font-size-34: 34px; + --font-size-32: 32px; + --font-size-28: 28px; + --font-size-24: 24px; + --font-size-20: 20px; + --font-size-16: 16px; + --font-size-14: 14px; + --font-size-12: 12px; + + --font-weight-bold: 700; + --font-weight-semibold: 600; + --font-weight-medium: 500; + --font-weight-regular: 400; + + --line-height-normal: normal; + --line-height-22: 22px; + + --letter-spacing: 0em; + --letter-spacing-1: -0.01em; + --letter-spacing-3: -0.03em; +} + +/* Background Effects */ +/* Bottom fade overlay - ๊ธฐ๊ธฐ ๊ฒ€์ƒ‰ ํ™”๋ฉด */ +.bg-effect-fade-bottom { + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 95%, rgba(0, 0, 0, 0.2) 100%); +} + +/* ํƒœ๊ทธ ๋’ค ๊ทธ๋ฆผ์ž */ +@layer utilities { + .border-shadow-black { + box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.25); + } + + .border-shadow-deep-black { + box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5); + } + + .border-shadow-gray { + box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.1); + } + + .border-shadow-blue { + box-shadow: 0 0 7px 0 var(--color-blue-400); + } + + .border-shadow-blue-double { + box-shadow: 0 0 16px 0 var(--color-blue-400); + } + + .border-shadow-blue-welcome { + box-shadow: 0 0 10px 0 var(--color-blue-600); + } + + .border-shadow-red { + box-shadow: 0 0 10px 0 var(--color-warning); + } + + .animate-fade-in { + animation: fadeIn 0.6s ease-out; + } +} + +@font-face { + font-family: 'KIMM_Bold'; + src: url('./assets/fonts/KIMM_bold.woff2') format('woff2'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +/* SERVICE NAME (34px) */ +.font-service-name { + font-family: var(--font-service); + font-size: var(--font-size-34); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing); +} + +/* service name (24px) */ +.font-service-name-sm { + font-family: var(--font-service); + font-size: var(--font-size-24); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing); +} + +@font-face { + font-family: 'WantedSans'; + src: url('./assets/fonts/WantedSansVariable.woff2') format('woff2'); + font-weight: 400 900; + font-style: normal; + font-display: swap; +} + +/* Heading1 */ +.font-heading-1 { + font-family: var(--font-main); + font-size: var(--font-size-40); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-1); +} + +/* Heading2 */ +.font-heading-2 { + font-family: var(--font-main); + font-size: var(--font-size-32); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-1); +} + +/* Heading3 */ +.font-heading-3 { + font-family: var(--font-main); + font-size: var(--font-size-28); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-1); +} + +/* Heading4 */ +.font-heading-4 { + font-family: var(--font-main); + font-size: var(--font-size-24); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-1); +} + + +/* Body1 (semibold) */ +.font-body-1-sm { + font-family: var(--font-main); + font-size: var(--font-size-20); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-1); +} + +/* body1 (regular) */ +.font-body-1-r { + font-family: var(--font-main); + font-size: var(--font-size-20); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-1); +} + +/* Body2 (semibold) */ +.font-body-2-sm { + font-family: var(--font-main); + font-size: var(--font-size-16); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-3); +} + +/* body2 (regular) */ +.font-body-2-r { + font-family: var(--font-main); + font-size: var(--font-size-16); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-22); + letter-spacing: var(--letter-spacing-3); +} + +/* Body3 (semibold) */ +.font-body-3-sm { + font-family: var(--font-main); + font-size: var(--font-size-16); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-1); +} + +/* body3 (regular) */ +.font-body-3-r { + font-family: var(--font-main); + font-size: var(--font-size-16); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-1); +} + +/* Body4 (semibold) */ +.font-body-4-sm { + font-family: var(--font-main); + font-size: var(--font-size-14); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-1); +} + +/* body4 (regular) */ +.font-body-4-r { + font-family: var(--font-main); + font-size: var(--font-size-14); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-1); +} + +/* Caption (semibold) */ +.font-caption-sm { + font-family: var(--font-main); + font-size: var(--font-size-12); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-1); +} + +/* caption (regular) */ +.font-caption-r { + font-family: var(--font-main); + font-size: var(--font-size-12); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-1); +} + +/* ๋ฐ‘์ค„ ํ˜ธ๋ฒ„๋ง */ +.link-underline { + position: relative; + display: inline-block; +} + +.link-underline::after { + content: ''; + position: absolute; + left: 0; + bottom: -2px; + width: 100%; + height: 1px; + background-color: white; + transform: scaleX(0); + transform-origin: left; + transition: transform 200ms ease; +} + +.link-underline:hover::after { + transform: scaleX(1); +} + +/* ์ €์žฅ ์™„๋ฃŒ ๋ชจ๋‹ฌ fade-in ์• ๋‹ˆ๋ฉ”์ด์…˜ */ +@keyframes fadeIn { + 0% { + opacity: 0; + transform: scale(0.95); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +/* device summary card: hover ์‹œ marquee animation */ +@keyframes device-marquee { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-100%); + } +} + +.device-card:hover .device-name { + animation: device-marquee 6s linear infinite; +} + +/* x์Šคํฌ๋กค ๋ฐฉ์ง€ + ๋ ˆ์ด์•„์›ƒ ์‹œํ”„ํŠธ ๋ฐฉ์ง€ */ +html { + overflow-x: hidden; + scrollbar-gutter: stable; +} +body { + overflow-x: hidden; +} + +/* ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ ์• ๋‹ˆ๋ฉ”์ด์…˜ */ +@keyframes linesAppear { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.animate-lines-appear { + animation: linesAppear 2s ease-out forwards; +} + +/* ๋ฏธ๋‹ˆ๋ฉ€ ์Šคํฌ๋กค๋ฐ” */ +.scrollbar-minimal { + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +.scrollbar-minimal:hover { + scrollbar-color: var(--color-gray-200) transparent; +} + +/* Webkit (Chrome, Safari, Edge) */ +.scrollbar-minimal::-webkit-scrollbar { + width: 6px; +} + +.scrollbar-minimal::-webkit-scrollbar-track { + background: transparent; +} + +.scrollbar-minimal::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: 1000px; + transition: background-color 0.2s ease; +} + +.scrollbar-minimal:hover::-webkit-scrollbar-thumb { + background-color: var(--color-gray-200); +} + +.scrollbar-minimal::-webkit-scrollbar-thumb:hover { + background-color: var(--color-gray-300); +} diff --git a/src/layouts/RootLayout.tsx b/src/layouts/RootLayout.tsx new file mode 100644 index 00000000..39c38c16 --- /dev/null +++ b/src/layouts/RootLayout.tsx @@ -0,0 +1,21 @@ +import GNB from '@/components/Home/GNB'; +import { Outlet } from 'react-router-dom'; +import { useGetUserProfile } from '@/apis/mypage/getUserProfile'; + +const RootLayout = () => { + // ๋ ˆ์ด์•„์›ƒ์—์„œ ์œ ์ € ํ”„๋กœํ•„ ์กฐํšŒ ํŠธ๋ฆฌ๊ฑฐ (๋‚˜๋จธ์ง€ ์ปดํฌ๋„ŒํŠธ๋Š” useAuth๋กœ ๊ตฌ๋…) + useGetUserProfile(); + + return ( +
+
+ +
+ +
+
+
+ ); +}; + +export default RootLayout; diff --git a/src/main.tsx b/src/main.tsx index 005972c4..51db7900 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,9 +1,24 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import App from '@/App'; +import './index.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}); createRoot(document.getElementById('root')!).render( - + + + + ); diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 00000000..636bae1f --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { ROTATION_MS } from '@/constants/time'; +import HomeImage1 from '@/assets/images/home/HomeImage1.svg?react'; +import HomeImage2 from '@/assets/images/home/HomeImage2.svg?react'; +import HomeImage3 from '@/assets/images/home/HomeImage3.svg?react'; +import ConnectivitySection from '@/components/Home/ConnectivitySection'; +import PortabilitySection from '@/components/Home/PortabilitySection'; +import LifestyleSection from '@/components/Home/LifestyleSection'; +import LogicEvaluationSection from '@/components/Home/LogicEvaluationSection'; +import Footer from '@/components/Home/Footer'; + +const IMAGES = [HomeImage1, HomeImage2, HomeImage3]; + +const HomePage = () => { + const [index, setIndex] = useState(0); + + useEffect(() => { + const intervalId = window.setInterval(() => { + setIndex((prev) => (prev + 1) % IMAGES.length); + }, ROTATION_MS); + + return () => window.clearInterval(intervalId); + }, []); + + return ( +
+
+
+ {IMAGES.map((Img, i) => ( + + ))} +
+ +
+
+
+

์Šค๋งˆํŠธํ•œ ํ‰๊ฐ€ ์‹œ์Šคํ…œ

+
+
+ + +
+
+ + +
+
+
+
+
+
+ ); +}; + +export default HomePage; diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx new file mode 100644 index 00000000..bb7aa50b --- /dev/null +++ b/src/pages/NotFoundPage.tsx @@ -0,0 +1,19 @@ +import Error404 from '@/assets/images/error/Error404.svg?react'; +import SecondaryButton from '@/components/Button/SecondaryButton' +import { useNavigate } from 'react-router-dom'; + +const NotFoundPage = () => { + const navigate = useNavigate(); + + return ( +
+
+

Page Disconnected

+ + navigate('/')} /> +
+
+ ); +}; + +export default NotFoundPage; diff --git a/src/pages/auth/FindIdPage.tsx b/src/pages/auth/FindIdPage.tsx new file mode 100644 index 00000000..6f58ea9d --- /dev/null +++ b/src/pages/auth/FindIdPage.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import PrimaryInput from '@/components/Input/PrimaryInput'; +import { ROUTES } from '@/constants/routes'; +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { findIdSchema, type FindIdFormData } from '@/schemas/authSchema'; +import { usePostFindId } from '@/apis/findCredential/postFindId'; +import { parseApiError } from '@/utils/error'; + +const FindIdPage = () => { + const navigate = useNavigate(); + const [hasSubmitted, setHasSubmitted] = useState(false); + const { mutateAsync: findId, isPending } = usePostFindId(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(findIdSchema), + mode: 'onSubmit', + reValidateMode: 'onChange', // ํ•œ ๋ฒˆ ์ œ์ถœ ํ›„์—๋Š” ์ž…๋ ฅ ์‹œ๋งˆ๋‹ค ์žฌ๊ฒ€์‚ฌ + }); + + // ์•„์ด๋”” ์ฐพ๊ธฐ ์ œ์ถœ ํ•ธ๋“ค๋Ÿฌ (์œ ํšจํ•  ๋•Œ๋งŒ ํ˜ธ์ถœ) + const onSubmitValid = async (data: FindIdFormData) => { + try { + const response = await findId({ + username: data.name, + phoneNumber: data.phone, + }); + + // ์•„์ด๋”” ์ฐพ๊ธฐ ์„ฑ๊ณต + navigate(ROUTES.auth.findIdResult, { + state: { + success: true, + email: response.result?.emailInfo, + }, + }); + } catch (error: unknown) { + const { hasResponse, message } = parseApiError(error); + + // API ์‘๋‹ต์ด ์žˆ๋Š” ๊ฒฝ์šฐ โ†’ ์‹คํŒจ ํ™”๋ฉด์œผ๋กœ ์ด๋™ (๋ฐฑ์—”๋“œ message ์ „๋‹ฌ) + if (hasResponse) { + navigate(ROUTES.auth.findIdResult, { + state: { + success: false, + email: null, + message, + }, + }); + return; + } + + // ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜ ๋“ฑ ์‘๋‹ต ์ž์ฒด๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ โ†’ alert + alert('์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.'); + } + }; + + // ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์‹คํŒจ ์‹œ ํ•œ ๋ฒˆ์ด๋ผ๋„ ์ œ์ถœํ–ˆ์Œ์„ ํ‘œ์‹œ โ†’ ์ดํ›„ ์‹ค์‹œ๊ฐ„ ๊ฒ€์‚ฌ + const onSubmitInvalid = () => { + setHasSubmitted(true); + }; + + return ( +
+ {/* ์ „์ฒด ์ปจํ…Œ์ด๋„ˆ */} +
+ {/* ๋ฉ”์ธ ํผ ์˜์—ญ */} +
+ {/* ๋กœ๊ณ  */} +

Device Life

+ + {/* ํผ ์ปจํ…Œ์ด๋„ˆ */} +
+ {/* ์ž…๋ ฅ์ฐฝ๋“ค */} +
+ {/* ์ด๋ฆ„ */} +
+ + {hasSubmitted && errors.name && ( +

{errors.name.message}

+ )} +
+ {/* ํœด๋Œ€ํฐ ๋ฒˆํ˜ธ */} +
+ + {hasSubmitted && errors.phone && ( +

{errors.phone.message}

+ )} +
+
+ + {/* ์•„์ด๋”” ์ฐพ๊ธฐ ๋ฒ„ํŠผ */} + + + + + +
+
+
+ ); +}; + +export default FindIdPage; diff --git a/src/pages/auth/FindIdResultPage.tsx b/src/pages/auth/FindIdResultPage.tsx new file mode 100644 index 00000000..aaec1180 --- /dev/null +++ b/src/pages/auth/FindIdResultPage.tsx @@ -0,0 +1,109 @@ +import PrimaryButton from "@/components/Button/PrimaryButton"; +import OnboardingLifestyleTag from "@/components/Lifestyle/OnboardingLifestyleTag"; +import { ROUTES } from "@/constants/routes"; +import { useNavigate, useLocation, Navigate } from "react-router-dom"; +import WarningIcon from "@/assets/icons/warning.svg?react"; + +// ๋ผ์šฐํ„ฐ state ํƒ€์ž… +type FindIdResultState = { + success?: boolean; + email?: string | null; + message?: string; +}; + +const FindIdResultPage = () => { + const navigate = useNavigate(); + const location = useLocation(); + + // ๋ผ์šฐํ„ฐ state์—์„œ ๊ฒฐ๊ณผ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + const state = location.state as FindIdResultState | null; + + // ๋ผ์šฐํ„ฐ state๊ฐ€ ์—†์œผ๋ฉด ์•„์ด๋”” ์ฐพ๊ธฐ ํŽ˜์ด์ง€๋กœ ์ด๋™ + if (!state) { + return ; + } + + const { success, email, message } = state; + const isSuccess = success && email; + + return ( +
+ {/* ์•„์ด๋”” ์ฐพ๊ธฐ ๊ฒฐ๊ณผ ํ”„๋ ˆ์ž„ ๋ ˆ์ด์•„์›ƒ (์„ฑ๊ณต/์‹คํŒจ ๊ณตํ†ต) */} +
+ {isSuccess ? ( + <> + {/* ์ƒ๋‹จ ์•ˆ๋‚ด ๋ฌธ๊ตฌ ์˜์—ญ - ์„ฑ๊ณต */} +

+ ํšŒ์›๋‹˜์˜ ์•„์ด๋””๋ฅผ ํ™•์ธํ•ด ์ฃผ์„ธ์š” +

+ + {/* ์•„์ด๋”” ํ‘œ์‹œ ์˜์—ญ - ์„ฑ๊ณต */} + + + {/* ํ•˜๋‹จ ๋ฒ„ํŠผ ์˜์—ญ - ์„ฑ๊ณต (๋กœ๊ทธ์ธ / ๋น„๋ฐ€๋ฒˆํ˜ธ ์ฐพ๊ธฐ) */} +
+ navigate(ROUTES.auth.login, { state: { prefillEmail: email } })} + /> +
+ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ƒ๊ฐ๋‚˜์ง€ ์•Š์œผ์‹ ๊ฐ€์š”? + +
+
+ + ) : ( + <> + {/* ์ƒ๋‹จ ์•ˆ๋‚ด ๋ฌธ๊ตฌ ์˜์—ญ - ์‹คํŒจ */} +

+ ํšŒ์›๋‹˜์˜ ์•„์ด๋””๋ฅผ ํ™•์ธํ•ด ์ฃผ์„ธ์š” +

+ + {/* ๋ฉ”์‹œ์ง€ ์˜์—ญ - ์‹คํŒจ*/} +
+ {/* ์‹คํŒจ ์•„์ด์ฝ˜ ์˜์—ญ - ์‹ค์ œ ์•„์ด์ฝ˜ ์ปดํฌ๋„ŒํŠธ๋Š” ์ถ”ํ›„ ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ๋กœ ๋ถ„๋ฆฌ ๊ฐ€๋Šฅ */} + + + {/* ์‹คํŒจ ๋ฉ”์‹œ์ง€ ํ…์ŠคํŠธ (๋ฐฑ์—”๋“œ message ์žˆ์œผ๋ฉด ํ‘œ์‹œ, ์—†์œผ๋ฉด ๊ธฐ๋ณธ ๋ฌธ๊ตฌ) */} +

+ {message || '์กฐํšŒ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'} +

+
+ + {/* ํ•˜๋‹จ ๋ฒ„ํŠผ ์˜์—ญ - ์‹คํŒจ */} +
+ navigate(ROUTES.auth.login)} + /> +
+ ์•„์ง Device Life ํšŒ์›์ด ์•„๋‹ˆ์‹ ๊ฐ€์š”? + +
+
+ + )} +
+
+ ); +} + +export default FindIdResultPage \ No newline at end of file diff --git a/src/pages/auth/FindPasswordPage.tsx b/src/pages/auth/FindPasswordPage.tsx new file mode 100644 index 00000000..f140252c --- /dev/null +++ b/src/pages/auth/FindPasswordPage.tsx @@ -0,0 +1,184 @@ +import { useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ROUTES } from '@/constants/routes'; +import { + type FindPasswordFormData, + type ResetPasswordFormData, +} from '@/schemas/authSchema'; +import { + usePostSendMail, + usePostVerifyCode, + usePostResetPassword, +} from '@/apis/findCredential/postFindPassword'; +import Step1Form from '@/components/Auth/FindPasswordStep/Step1Form'; +import Step2Verification from '@/components/Auth/FindPasswordStep/Step2Verification'; +import Step3Reset from '@/components/Auth/FindPasswordStep/Step3Reset'; +import useTimer from '@/hooks/useTimer'; +import { parseApiError } from '@/utils/error'; + +const TIMER_SECONDS = 180; // 3๋ถ„ +const RESEND_COOLDOWN_SECONDS = 60; // ์žฌ์ „์†ก ์ฟจ๋‹ค์šด 60์ดˆ +const RESEND_LIMIT_MESSAGE = '๋งˆ์ง€๋ง‰ ์ธ์ฆ๋ฒˆํ˜ธ๊ฐ€ ๋ฐœ์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค. (์žฌ์ „์†ก ํšŸ์ˆ˜ ์ดˆ๊ณผ)'; + +const FindPasswordPage = () => { + const navigate = useNavigate(); + const [step, setStep] = useState<1 | 2 | 3>(1); + const [hasSubmitted, setHasSubmitted] = useState(false); + const [email, setEmail] = useState(''); + const [verificationCode, setVerificationCode] = useState(''); + const [verifyToken, setVerifyToken] = useState(''); + const [verifyError, setVerifyError] = useState(''); + const [resetError, setResetError] = useState(''); + const [resendCount, setResendCount] = useState(0); + + const { mutateAsync: sendMail, isPending } = usePostSendMail(); + const { mutateAsync: verifyCode, isPending: isVerifyPending } = usePostVerifyCode(); + const { mutateAsync: resetPassword, isPending: isResetPending } = usePostResetPassword(); + + // ํƒ€์ด๋จธ ๋งŒ๋ฃŒ ์‹œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ (useCallback์œผ๋กœ ์ฐธ์กฐ ์•ˆ์ •ํ™”) + const handleTimerExpire = useCallback(() => { + setVerifyError('์ธ์ฆ ์‹œ๊ฐ„์ด ๋งŒ๋ฃŒ๋˜์—ˆ์–ด์š”. ์ธ์ฆ๋ฒˆํ˜ธ๋ฅผ ๋‹ค์‹œ ๋ฐ›์•„์ฃผ์„ธ์š”.'); + }, []); + + // ํƒ€์ด๋จธ ํ›… ์‚ฌ์šฉ (step์ด 2์ผ ๋•Œ๋งŒ ํ™œ์„ฑํ™”) + const { timeLeft, start: startTimer } = useTimer({ + initialSeconds: TIMER_SECONDS, + enabled: step === 2, + onExpire: handleTimerExpire, + }); + + // ์žฌ์ „์†ก ์ฟจ๋‹ค์šด ํƒ€์ด๋จธ (60์ดˆ) + const { start: startResendCooldown, isRunning: isResendCooldownRunning } = useTimer({ + initialSeconds: RESEND_COOLDOWN_SECONDS, + enabled: step === 2, + }); + + // Step1: ์ธ์ฆ๋ฒˆํ˜ธ ๋ฐ›๊ธฐ ์ œ์ถœ ํ•ธ๋“ค๋Ÿฌ + const handleStep1Submit = async (data: FindPasswordFormData) => { + try { + await sendMail({ email: data.email }); + setEmail(data.email); + setStep(2); + startTimer(); // ํƒ€์ด๋จธ ์‹œ์ž‘ + } catch (error: unknown) { + throw error; // Step1Form์—์„œ ์—๋Ÿฌ ์ฒ˜๋ฆฌํ•˜๋„๋ก ์ „๋‹ฌ + } + }; + + // Step1: ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์‹คํŒจ ํ•ธ๋“ค๋Ÿฌ + const handleStep1Invalid = () => { + setHasSubmitted(true); + }; + + // Step2: ์ธ์ฆ๋ฒˆํ˜ธ ์žฌ์ „์†ก (์ตœ๋Œ€ 3ํšŒ) + const handleResend = async () => { + setVerifyError(''); + try { + await sendMail({ email }); + setResendCount((prev) => { + const next = prev + 1; + if (next >= 3) { + setVerifyError(RESEND_LIMIT_MESSAGE); + } + return next; + }); + startTimer(); + startResendCooldown(); // ์žฌ์ „์†ก ์ฟจ๋‹ค์šด ํƒ€์ด๋จธ ์‹œ์ž‘ + setVerificationCode(''); + } catch (error: unknown) { + const { hasResponse, message } = parseApiError(error); + if (hasResponse && message) { + alert(message); + } else if (hasResponse) { + alert('์ธ์ฆ๋ฒˆํ˜ธ ์žฌ์ „์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.'); + } else { + alert('์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.'); + } + } + }; + + // Step2: ์ธ์ฆ๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ํ•ธ๋“ค๋Ÿฌ (์žฌ์ „์†ก 3ํšŒ ์ดˆ๊ณผ ์‹œ ๋ฉ”์‹œ์ง€๋Š” ์ž…๋ ฅํ•ด๋„ ์œ ์ง€) + const handleVerificationCodeChange = (value: string) => { + setVerificationCode(value); + if (resendCount < 3) setVerifyError(''); + }; + + // Step2: ์ธ์ฆ๋ฒˆํ˜ธ ํ™•์ธ API ์—ฐ๋™ + const handleVerify = async () => { + setVerifyError(''); + + try { + const response = await verifyCode({ code: verificationCode }); + + if (response.result?.verifyToken) { + setVerifyToken(response.result.verifyToken); + setStep(3); + } + } catch (error: unknown) { + const { hasResponse, message } = parseApiError(error); + if (hasResponse) { + setVerifyError(message ?? '์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.'); + return; + } + alert('์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.'); + } + }; + + // Step3: ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ API ์—ฐ๋™ + const handleResetPassword = async (data: ResetPasswordFormData) => { + setResetError(''); + + try { + await resetPassword({ + verifiedToken: verifyToken, + newPassword: data.newPassword, + }); + + navigate(ROUTES.auth.login); + } catch (error: unknown) { + const { hasResponse, message } = parseApiError(error); + if (hasResponse) { + setResetError(message ?? '๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.'); + return; + } + alert('์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.'); + } + }; + + return ( +
+ {step === 1 && ( + + )} + + {step === 2 && ( + = 3 || isResendCooldownRunning} + /> + )} + + {step === 3 && ( + setResetError('')} + /> + )} +
+ ); +}; + +export default FindPasswordPage; diff --git a/src/pages/auth/GoogleCallbackPage.tsx b/src/pages/auth/GoogleCallbackPage.tsx new file mode 100644 index 00000000..cded4643 --- /dev/null +++ b/src/pages/auth/GoogleCallbackPage.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import { postRefresh } from '@/apis/auth/postRefresh'; +import { finalizeLogin } from '@/utils/finalizeLogin'; +import { ROUTES } from '@/constants/routes'; +import { queryKey } from '@/constants/queryKey'; +import type { UserProfileResult } from '@/types/mypage/user'; + +const GoogleCallbackPage = () => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const hasHandled = useRef(false); + + useEffect(() => { + // ์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ๊ฒฝ์šฐ ์ข…๋ฃŒ(StrictMode ๋•Œ๋ฌธ์— ์—ฌ๋Ÿฌ๋ฒˆ ํ˜ธ์ถœ๋  ์ˆ˜ ์žˆ์Œ) + const run = async () => { + if (hasHandled.current) return; + hasHandled.current = true; + + try { + // 1) refreshToken ์ฟ ํ‚ค๋กœ accessToken ๋ฐœ๊ธ‰ + const refreshResponse = await postRefresh(); + const accessToken = refreshResponse?.result?.accessToken; + if (!accessToken) throw new Error('์•ก์„ธ์Šค ํ† ํฐ์„ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + + // 2) accessToken ์ €์žฅ + ์œ ์ € ์บ์‹œ ์„ธํŒ… (OAuth๋Š” ๋กœ๊ทธ์ธ ์ƒํƒœ ์œ ์ง€๋กœ ์ฒ˜๋ฆฌ -> local ์ €์žฅ) + await finalizeLogin(accessToken, queryClient, true); + + // 3) ์บ์‹œ์—์„œ ์œ ์ € ๊บผ๋‚ด์„œ ๋ถ„๊ธฐ + const user = queryClient.getQueryData([ + queryKey.USER_PROFILE, + ]); + + // ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ ์—ฌ๋ถ€ ํ™•์ธ ํ›„ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + if (user?.isOnboardingCompleted) { + navigate(ROUTES.home, { replace: true }); + } else { + navigate(ROUTES.onboarding.lifestyle, { replace: true }); + } + // ์‹คํŒจ ์‹œ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + } catch { + alert('๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); + navigate(ROUTES.auth.login, { replace: true, }); + } + }; + + run(); + }, [navigate, queryClient]); + + return ; +}; + +export default GoogleCallbackPage; diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx new file mode 100644 index 00000000..24995b57 --- /dev/null +++ b/src/pages/auth/LoginPage.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { loginSchema, type LoginFormData } from '@/schemas/authSchema'; +import PrimaryInput from '@/components/Input/PrimaryInput'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import Checkbox from '@/assets/icons/checkbox.svg?react'; +import CheckboxOn from '@/assets/icons/checkbox_on.svg?react'; +import { useNavigate, useLocation, Navigate } from 'react-router-dom'; +import { ROUTES } from '@/constants/routes'; +import GoogleLoginButton from '@/components/Button/GoogleLoginButton'; +import { useLogin } from '@/hooks/useLogin'; +import { useAuth } from '@/hooks/useAuth'; + +// ๋ผ์šฐํ„ฐ state ํƒ€์ž… (์•„์ด๋”” ์ฐพ๊ธฐ์—์„œ ๋„˜์–ด์˜ฌ ๋•Œ) +type LoginPageState = { + prefillEmail?: string; +}; + +const LoginPage = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { isLoggedIn, hasCompletedOnboarding } = useAuth(); + const [keepLogin, setKeepLogin] = useState(false); + const [isCapsLockOn, setIsCapsLockOn] = useState(false); + const [loginError, setLoginError] = useState(''); + + // ์•„์ด๋”” ์ฐพ๊ธฐ์—์„œ ๋„˜์–ด์˜จ ์ด๋ฉ”์ผ (์žˆ์œผ๋ฉด ์ž๋™ ์ž…๋ ฅ) + const { prefillEmail } = (location.state || {}) as LoginPageState; + + // ๋กœ๊ทธ์ธ ํผ ์ƒํƒœ ๊ด€๋ฆฌ + const { + register, // input๊ณผ ํผ ์—ฐ๊ฒฐํ•˜๋Š” ํ•จ์ˆ˜ + handleSubmit, // ํผ ์ œ์ถœ ์ฒ˜๋ฆฌ ํ•จ์ˆ˜ + formState: { errors, isValid }, // errors: ์—๋Ÿฌ ๋ฉ”์‹œ์ง€, isValid: ํผ ์œ ํšจ ์—ฌ๋ถ€ + } = useForm({ + resolver: zodResolver(loginSchema), // zod ์Šคํ‚ค๋งˆ๋กœ ๊ฒ€์‚ฌํ•ด์ค˜! + mode: 'onChange', // ์ž…๋ ฅํ•  ๋•Œ๋งˆ๋‹ค ๊ฒ€์‚ฌ + defaultValues: { + email: prefillEmail || '', + }, + }); + + // ๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ ํ•„๋“œ ๋“ฑ๋ก + const passwordRegister = register('password'); + + // ๋กœ๊ทธ์ธ ํ›… + const { loginAndFinalize, isPending } = useLogin(); + + // ๋กœ๊ทธ์ธ๋œ ์ƒํƒœ์—์„œ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€ ์ ‘๊ทผ ์‹œ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + if (isLoggedIn) { + const destination = hasCompletedOnboarding + ? ROUTES.home + : ROUTES.onboarding.lifestyle; + return ; + } + + // ๋กœ๊ทธ์ธ ์ œ์ถœ ํ•ธ๋“ค๋Ÿฌ + const onSubmit = async (data: LoginFormData) => { + setLoginError(''); + + try { + await loginAndFinalize({ + email: data.email, + password: data.password, + keepLogin, + }); + // ์บ์‹œ ์—…๋ฐ์ดํŠธ โ†’ ๋ฆฌ๋ Œ๋”๋ง โ†’ isLoggedIn ๊ฐ€๋“œ๊ฐ€ ์ž๋™์œผ๋กœ ๋ผ์šฐํŒ… ์ฒ˜๋ฆฌ + } catch (error) { + // ๋กœ๊ทธ์ธ ์‹คํŒจ ์‹œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ + setLoginError('์•„์ด๋”” ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ํ™•์ธํ•ด์ฃผ์„ธ์š”.'); + } + }; + + return ( +
+ {/* ์ „์ฒด ์ปจํ…Œ์ด๋„ˆ */} +
+ {/* ๋ฉ”์ธ ํผ ์˜์—ญ */} +
+ {/* ๋กœ๊ณ  */} +

Device Life

+ + {/* ํผ ์ปจํ…Œ์ด๋„ˆ */} +
+ {/* ์ž…๋ ฅ ์˜์—ญ */} +
+ {/* ์ž…๋ ฅ์ฐฝ๋“ค */} +
+ { + setLoginError(''); + }, + })} + type="email" + placeholder="์ด๋ฉ”์ผ" + /> +
+ ) => { + setIsCapsLockOn(e.getModifierState('CapsLock')); + }} + onChange={(e) => { + passwordRegister.onChange(e); + setLoginError(''); + }} + /> + {(errors.password || isCapsLockOn || loginError) && ( +

+ {errors.password?.message || + (isCapsLockOn ? 'Caps Lock์ด ์ผœ์ ธ ์žˆ์Šต๋‹ˆ๋‹ค.' : '') || + loginError} +

+ )} +
+ {/* ์ฒดํฌ๋ฐ•์Šค */} + +
+ +
+ +
+
+ + {/* ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ + ์•„์ด๋””/๋น„๋ฐ€๋ฒˆํ˜ธ ์ฐพ๊ธฐ + ์†Œ์…œ ๋กœ๊ทธ์ธ + ํšŒ์›๊ฐ€์ž… ์•ˆ๋‚ด */} +
+ {/* ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ + ์•„์ด๋””/๋น„๋ฐ€๋ฒˆํ˜ธ ์ฐพ๊ธฐ */} +
+ {/* ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ */} + + + {/* ์•„์ด๋””/๋น„๋ฐ€๋ฒˆํ˜ธ ์ฐพ๊ธฐ */} +
+ + | + +
+
+ + {/* ์†Œ์…œ ๋กœ๊ทธ์ธ */} + + + {/* ํšŒ์›๊ฐ€์ž… ์•ˆ๋‚ด */} +
+ ์•„์ง Device Life ํšŒ์›์ด ์•„๋‹ˆ์‹ ๊ฐ€์š”? + +
+
+
+
+ ); +}; + +export default LoginPage; diff --git a/src/pages/auth/SignupAccountPage.tsx b/src/pages/auth/SignupAccountPage.tsx new file mode 100644 index 00000000..21763950 --- /dev/null +++ b/src/pages/auth/SignupAccountPage.tsx @@ -0,0 +1,184 @@ +import { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { signupAccountSchema, type SignupAccountFormData } from '@/schemas/authSchema'; +import PrimaryInput from '@/components/Input/PrimaryInput'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import SecondaryButton from '@/components/Button/SecondaryButton'; +import InputLabel from '@/components/Auth/Label/InputLabel'; +import StepIndicator from '@/components/Auth/Indicator/StepIndicator'; +import { useNavigate, Navigate } from 'react-router-dom'; +import { ROUTES } from '@/constants/routes'; +import { useSignupStore } from '@/stores/signupStore'; +import { usePostJoinEmail } from '@/apis/auth/postJoinEmail'; +import { useAuth } from '@/hooks/useAuth'; + +const SignupAccountPage = () => { + const navigate = useNavigate(); + const { isLoggedIn } = useAuth(); + const [hasSubmitted, setHasSubmitted] = useState(false); + const [hasEmailSubmitted, setHasEmailSubmitted] = useState(false); + + const { setAccount, isEmailVerified, setIsEmailVerified } = useSignupStore(); + + // ํŽ˜์ด์ง€ ๋งˆ์šดํŠธ ์‹œ ์ด๋ฉ”์ผ ์ค‘๋ณตํ™•์ธ ์ƒํƒœ ์ดˆ๊ธฐํ™” (๋’ค๋กœ๊ฐ€๊ธฐ/์ธ๋””์ผ€์ดํ„ฐ๋กœ ๋Œ์•„์˜ฌ ๋•Œ ๋Œ€๋น„) + useEffect(() => { + setIsEmailVerified(false); + }, [setIsEmailVerified]); + const { mutateAsync: checkEmailDuplicate, isPending: isCheckingEmail } = usePostJoinEmail(); + + const { + register, + handleSubmit, + formState: { errors }, + setError, + clearErrors, + trigger, + getValues, + } = useForm({ + resolver: zodResolver(signupAccountSchema), + // ์ตœ์ดˆ์—๋Š” ์—๋Ÿฌ๋ฅผ ์ˆจ๊ธฐ๊ณ , submit ์ดํ›„์—๋Š” onChange๋กœ ์‹ค์‹œ๊ฐ„ ๊ฐฑ์‹ ๋˜๋„๋ก + mode: 'onChange', + reValidateMode: 'onChange', + }); + + // ๋กœ๊ทธ์ธ๋œ ์ƒํƒœ์—์„œ ํšŒ์›๊ฐ€์ž… ํŽ˜์ด์ง€ ์ ‘๊ทผ ์‹œ ํ™ˆ์œผ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + if (isLoggedIn) { + return ; + } + + // ์ด๋ฉ”์ผ ์ค‘๋ณตํ™•์ธ ํ•ธ๋“ค๋Ÿฌ + const handleCheckDuplicate = async () => { + setHasEmailSubmitted(true); + // ์ด๋ฉ”์ผ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ + const isEmailValid = await trigger('email'); + if (!isEmailValid) return; + + // ์ด๋ฉ”์ผ ๊ฐ’ ๊ฐ€์ ธ์˜ค๊ธฐ + const email = getValues('email'); + + // ์ด๋ฉ”์ผ ์ค‘๋ณตํ™•์ธ ์š”์ฒญ + try { + const data = await checkEmailDuplicate({ email }); + if (!data.result?.success) { + setError('email', { + type: 'manual', + message: '์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค.', + }); + return; + } + setIsEmailVerified(true); + clearErrors('email'); + } catch (error) { + setError('email', { + type: 'manual', + message: '์ด๋ฉ”์ผ ์ค‘๋ณตํ™•์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.', + }); + } + }; + + // ๊ณ„์ • ์ •๋ณด ์ œ์ถœ ์„ฑ๊ณต ํ•ธ๋“ค๋Ÿฌ + const onSubmitValid = (data: SignupAccountFormData) => { + setHasSubmitted(true); + + // ์•„์ง ์ค‘๋ณตํ™•์ธ์ด ์™„๋ฃŒ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ + if (!isEmailVerified) { + setError('email', { + type: 'manual', + message: '์ด๋ฉ”์ผ ์ค‘๋ณตํ™•์ธ์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”.', + }); + return; + } + + // zustand์— ๊ณ„์ • ์ •๋ณด ์ €์žฅ (API ํ˜ธ์ถœ ์‹œ ํ•œ ๋ฒˆ์— ์‚ฌ์šฉ) + setAccount({ + email: data.email, + password: data.password, + }); + // ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋กœ ์ด๋™ + navigate(ROUTES.auth.signup.profile); + }; + + // ๊ณ„์ • ์ •๋ณด ์ œ์ถœ ์‹คํŒจ ํ•ธ๋“ค๋Ÿฌ + const onSubmitInvalid = () => { + // ์ตœ์ดˆ submit ์ดํ›„๋ถ€ํ„ฐ ์—๋Ÿฌ๋ฅผ ๋…ธ์ถœ + ์‹ค์‹œ๊ฐ„ ๊ฐฑ์‹  + setHasSubmitted(true); + }; + + return ( +
+ {/* ์ „์ฒด ์ปจํ…Œ์ด๋„ˆ */} +
+ {/* ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ธ๋””์ผ€์ดํ„ฐ */} + + + {/* ํผ ์ปจํ…Œ์ด๋„ˆ */} +
+ {/* ๋กœ๊ณ  */} +

Device Life

+ {/* ํผ ํ•„๋“œ ์˜์—ญ */} +
+ {/* ์ด๋ฉ”์ผ ํ•„๋“œ */} +
+
+ + { + setIsEmailVerified(false); + }, + })} + type="email" + placeholder="์ด๋ฉ”์ผ" + disabled={isEmailVerified} + /> + +
+ {(hasSubmitted || hasEmailSubmitted) && errors.email && ( +

{errors.email.message}

+ )} + {hasEmailSubmitted && !errors.email && isEmailVerified && ( +

์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค

+ )} +
+ + {/* ๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ์ฐฝ */} +
+
+ + +
+ {hasSubmitted && errors.password && ( +

{errors.password.message}

+ )} +
+ + {/* ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ ์ž…๋ ฅ์ฐฝ */} +
+
+ + +
+ {hasSubmitted && errors.passwordConfirm && ( +

{errors.passwordConfirm.message}

+ )} +
+
+ + {/* ๋‹ค์Œ ๋ฒ„ํŠผ */} + + +
+
+ ); +}; + +export default SignupAccountPage; diff --git a/src/pages/auth/SignupPage.tsx b/src/pages/auth/SignupPage.tsx new file mode 100644 index 00000000..fb1f8a6d --- /dev/null +++ b/src/pages/auth/SignupPage.tsx @@ -0,0 +1,49 @@ +import DeviceLifeLogo from '@/assets/logos/logo_circle.svg?react'; +import GoogleLogo from '@/assets/logos/google.svg?react'; +import SignupButton from '@/components/Button/SignupButton'; +import { ROUTES } from '@/constants/routes'; +import { OAUTH } from '@/constants/auth'; +import { useNavigate, Navigate } from 'react-router-dom'; +import { useAuth } from '@/hooks/useAuth'; + +const SignupPage = () => { + const navigate = useNavigate(); + const { isLoggedIn } = useAuth(); + + // ๋กœ๊ทธ์ธ๋œ ์ƒํƒœ์—์„œ ํšŒ์›๊ฐ€์ž… ํŽ˜์ด์ง€ ์ ‘๊ทผ ์‹œ ํ™ˆ์œผ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + if (isLoggedIn) { + return ; + } + + return ( +
+ {/* ์ „์ฒด ์ปจํ…Œ์ด๋„ˆ */} +
+ {/* ๋กœ๊ณ  + ํšŒ์›๊ฐ€์ž… ๋ฒ„ํŠผ๋“ค */} +
+ {/* ๋กœ๊ณ  */} +

Device Life

+ {/* ํšŒ์›๊ฐ€์ž… ๋ฒ„ํŠผ๋“ค */} +
+ } onClick={() => navigate(ROUTES.auth.signup.account)} textStart={132} /> + } textStart={146} onClick={() => { window.location.href = OAUTH.google; }} /> +
+
+ + {/* ๋กœ๊ทธ์ธ ์•ˆ๋‚ด */} +
+ ์ด๋ฏธ Device Life ๊ณ„์ •์ด ์žˆ์œผ์‹ ๊ฐ€์š”? + +
+
+
+ ); +}; + +export default SignupPage; diff --git a/src/pages/auth/SignupProfilePage.tsx b/src/pages/auth/SignupProfilePage.tsx new file mode 100644 index 00000000..eab35d6a --- /dev/null +++ b/src/pages/auth/SignupProfilePage.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { signupProfileSchema, type SignupProfileFormData } from '@/schemas/authSchema'; +import PrimaryInput from '@/components/Input/PrimaryInput'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import InputLabel from '@/components/Auth/Label/InputLabel'; +import StepIndicator from '@/components/Auth/Indicator/StepIndicator'; +import { useNavigate, Navigate } from 'react-router-dom'; +import { ROUTES } from '@/constants/routes'; +import { useSignupStore } from '@/stores/signupStore'; +import { usePostJoin } from '@/apis/auth/postJoin'; +import { useLogin } from '@/hooks/useLogin'; +import { useAuth } from '@/hooks/useAuth'; + +const SignupProfilePage = () => { + const navigate = useNavigate(); + const [hasSubmitted, setHasSubmitted] = useState(false); + const { account, isEmailVerified, resetSignup } = useSignupStore(); + const { mutateAsync: signup } = usePostJoin(); + const { loginAndFinalize } = useLogin(); + const { isLoggedIn, hasCompletedOnboarding } = useAuth(); + + // ํ”„๋กœํ•„ ์ •๋ณด ์ž…๋ ฅ ํผ ์ƒํƒœ ๊ด€๋ฆฌ + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(signupProfileSchema), + // ์ตœ์ดˆ์—๋Š” ์—๋Ÿฌ๋ฅผ ์ˆจ๊ธฐ๊ณ , submit ์ดํ›„์—๋Š” onChange๋กœ ์‹ค์‹œ๊ฐ„ ๊ฐฑ์‹ ๋˜๋„๋ก + mode: 'onChange', + reValidateMode: 'onChange', + }); + + // ๋กœ๊ทธ์ธ๋œ ์ƒํƒœ์—์„œ ํšŒ์›๊ฐ€์ž… ํŽ˜์ด์ง€ ์ ‘๊ทผ ์‹œ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + // (์ž๋™ ๋กœ๊ทธ์ธ ํ›„ ์˜จ๋ณด๋”ฉ ๋ฏธ์™„๋ฃŒ ์œ ์ €๋Š” ์˜จ๋ณด๋”ฉ์œผ๋กœ ๋ณด๋‚ด์•ผ ํ•จ) + if (isLoggedIn) { + const destination = hasCompletedOnboarding + ? ROUTES.home + : ROUTES.onboarding.lifestyle; + return ; + } + + // ์ด๋ฉ”์ผ, ๋น„๋ฐ€๋ฒˆํ˜ธ, ์ค‘๋ณตํ™•์ธ์ด ๋ชจ๋‘ ์™„๋ฃŒ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + const isAccountComplete = account.email && account.password && isEmailVerified; + + // ํ•˜๋‚˜๋ผ๋„ ๋น ์ง€๋ฉด ๊ณ„์ • ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + if (!isAccountComplete) { + return ; + } + + // ํ”„๋กœํ•„ ์ •๋ณด ์ œ์ถœ ์„ฑ๊ณต ํ•ธ๋“ค๋Ÿฌ + const onSubmitValid = async (data: SignupProfileFormData) => { + setHasSubmitted(true); + + // ํšŒ์›๊ฐ€์ž… API ํ˜ธ์ถœ + try { + await signup({ + email: account.email, + password: account.password, + username: data.name, + phoneNumber: data.phone, + }); + + // ํšŒ์›๊ฐ€์ž… ์„ฑ๊ณต ํ›„ ์ž๋™ ๋กœ๊ทธ์ธ + try { + await loginAndFinalize({ + email: account.email, + password: account.password, + keepLogin: false, + }); + + // ํšŒ์›๊ฐ€์ž… ์„ฑ๊ณต โ†’ zustand ์ดˆ๊ธฐํ™” (์ด๋ฉ”์ผ/๋น„๋ฐ€๋ฒˆํ˜ธ ๋ฉ”๋ชจ๋ฆฌ ์ •๋ฆฌ) + resetSignup(); + + // ๋กœ๊ทธ์ธ ์„ฑ๊ณต ์‹œ ์˜จ๋ณด๋”ฉ์œผ๋กœ ์ด๋™ + navigate(ROUTES.onboarding.lifestyle, { replace: true }); + } catch (loginError) { + // ์ž๋™ ๋กœ๊ทธ์ธ ์‹คํŒจํ•ด๋„ ํšŒ์›๊ฐ€์ž…์€ ์™„๋ฃŒ โ†’ zustand ์ดˆ๊ธฐํ™” + resetSignup(); + alert('ํšŒ์›๊ฐ€์ž…์€ ์™„๋ฃŒ๋˜์—ˆ์ง€๋งŒ ์ž๋™ ๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€์—์„œ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); + navigate(ROUTES.auth.login, { replace: true }); + } + } catch (error) { + alert('ํšŒ์›๊ฐ€์ž…์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); + } + }; + + // ํ”„๋กœํ•„ ์ •๋ณด ์ œ์ถœ ์‹คํŒจ ํ•ธ๋“ค๋Ÿฌ + const onSubmitInvalid = () => { + // ์ตœ์ดˆ submit ์ดํ›„๋ถ€ํ„ฐ ์—๋Ÿฌ๋ฅผ ๋…ธ์ถœ + ์‹ค์‹œ๊ฐ„ ๊ฐฑ์‹  + setHasSubmitted(true); + }; + + return ( +
+ {/* ์ „์ฒด ์ปจํ…Œ์ด๋„ˆ */} +
+ {/* ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ธ๋””์ผ€์ดํ„ฐ */} + + + + {/* ํผ ์ปจํ…Œ์ด๋„ˆ */} +
+ {/* ๋กœ๊ณ  */} +

Device Life

+ {/* ํผ ํ•„๋“œ ์˜์—ญ */} +
+ {/* ์ด๋ฆ„ ํ•„๋“œ */} +
+
+ + +
+ {hasSubmitted && errors.name && ( +

{errors.name.message}

+ )} +
+ + {/* ํœด๋Œ€ํฐ ๋ฒˆํ˜ธ ํ•„๋“œ */} +
+
+ + +
+ {hasSubmitted && errors.phone && ( +

{errors.phone.message}

+ )} +
+
+ + {/* ๋‹ค์Œ ๋ฒ„ํŠผ */} + + +
+
+ ); +}; + +export default SignupProfilePage; diff --git a/src/pages/combination/CombinationCreatePage.tsx b/src/pages/combination/CombinationCreatePage.tsx new file mode 100644 index 00000000..3fb3955d --- /dev/null +++ b/src/pages/combination/CombinationCreatePage.tsx @@ -0,0 +1,161 @@ +import { useMemo, useRef, useState } from 'react'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import Stage1Section from '@/components/Combination/Stage1Section'; +import Stage2Section from '@/components/Combination/Stage2Section'; +import Stage3Section from '@/components/Combination/Stage3Section'; +import CombinationResultOverlay from '@/components/Combination/CombinationResultOverlay'; +import CombinationStyleProbe from '@/components/Combination/CombinationStyleProbe'; +import { useCombinationMotion } from '@/hooks/useCombinationMotion'; +import { useCombinationNameInput } from '@/hooks/useCombinationNameInput'; +import { usePostCreateCombination } from '@/apis/combo/postCreateCombination'; +import axios from 'axios'; +import type { AxiosError } from 'axios'; +import type { CommonResponse } from '@/types/common'; + +type ResultPhase = 'idle' | 'shrink' | 'stack' | 'done'; + +const CombinationCreatePage = () => { + const [centerText, setCenterText] = useState(''); + const [mode, setMode] = useState<'form' | 'result'>('form'); + const [bgOn, setBgOn] = useState(false); + const [resultOn, setResultOn] = useState(false); + const [phase, setPhase] = useState('idle'); + const [showDouble, setShowDouble] = useState(false); + const [showExtras, setShowExtras] = useState(false); + const [serverErrorMessage, setServerErrorMessage] = useState(null); + const inputRef = useRef(null); + const styleProbeRef = useRef(null); + const targetRef = useRef(null); + const submitLockedRef = useRef(false); + + const { + value: name, + onChange: onNameChange, + onCompositionStart, + onCompositionEnd, + errorMessage, + isValid, + validate, + } = useCombinationNameInput({ + inputRef, + maxLen: 20, + }); + + const { start } = useCombinationMotion({ + inputRef, + styleProbeRef, + targetRef, + setCenterText, + setMode, + setResultOn, + setPhase, + setShowDouble, + setShowExtras, + }); + + const { mutateAsync } = usePostCreateCombination(); + + const handleCreate = async () => { + if (submitLockedRef.current) return; + if (!isValid) return; + if (validate(name) !== null) return; + submitLockedRef.current = true; + try { + setServerErrorMessage(null); + const res = await mutateAsync({ comboName: name }); + if (res.success && res.result) { + setBgOn(true); + start(res.result.comboName); + } + } catch (err) { + if (axios.isAxiosError(err)) { + const axiosErr = err as AxiosError>; + const code = axiosErr.response?.data?.code; + + // 401 ์—๋Ÿฌ๋Š” ์ธํ„ฐ์…‰ํ„ฐ์—์„œ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ์ฒ˜๋ฆฌ + // (๋น„๋กœ๊ทธ์ธ ์œ ์ €์˜ ๊ฒฝ์šฐ ์ธํ„ฐ์…‰ํ„ฐ๊ฐ€ ์ด๋ฏธ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ–ˆ์œผ๋ฏ€๋กœ ์—ฌ๊ธฐ์„œ๋Š” ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์Œ) + + if (code === 'COMBO_4005') { + setServerErrorMessage('์ด๋ฏธ ๋™์ผํ•œ ์ด๋ฆ„์˜ ์กฐํ•ฉ์ด ์กด์žฌํ•ฉ๋‹ˆ๋‹ค.'); + return; + } + } + setServerErrorMessage('์กฐํ•ฉ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); + } finally { + submitLockedRef.current = false; + } + }; + + const helperText = + serverErrorMessage ?? + errorMessage ?? + 'ํšŒ์›์˜ ๊ฒฝ์šฐ ๋กœ๊ทธ์ธ ํ•œ ๋’ค ์กฐํ•ฉ์„ ์ƒ์„ฑํ•ด์•ผ ๋งˆ์ดํŽ˜์ด์ง€>๋‚ด ์กฐํ•ฉ ๋ชฉ๋ก์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.'; + const buttonClass = useMemo( + () => `w-280 ${isValid ? 'bg-blue-600 hover:bg-blue-500' : 'bg-gray-300 cursor-not-allowed'}`, + [isValid] + ); + + return ( +
+
+ +
+ + {mode === 'form' && ( + <> +
+
+ { + setServerErrorMessage(null); + onNameChange(e); + }} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} + onKeyDown={(e) => { + if (e.key !== 'Enter') return; + if (submitLockedRef.current) return; + e.preventDefault(); + if (isValid) handleCreate(); + }} + className="w-500 h-52 px-20 py-20 rounded-button bg-blue-100 placeholder-gray-300 font-body-2-r outline-none" + /> +

{helperText}

+
+ +
+
+ + + +
+ + )} +
+
+ ); +}; + +export default CombinationCreatePage; diff --git a/src/pages/devices/DeviceDetailPage.tsx b/src/pages/devices/DeviceDetailPage.tsx new file mode 100644 index 00000000..db1dc282 --- /dev/null +++ b/src/pages/devices/DeviceDetailPage.tsx @@ -0,0 +1,5 @@ +const DeviceDetailPage = () => { + return
DeviceDetailPage
; +}; + +export default DeviceDetailPage; diff --git a/src/pages/devices/DeviceSearchPage.tsx b/src/pages/devices/DeviceSearchPage.tsx new file mode 100644 index 00000000..5a2c990a --- /dev/null +++ b/src/pages/devices/DeviceSearchPage.tsx @@ -0,0 +1,329 @@ +import { useRef, useLayoutEffect, useCallback, useMemo, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import GNB from '@/components/Home/GNB'; +import ProductCard from '@/components/ProductCard/ProductCard'; +import FilterDropdown from '@/components/Filter/FilterDropdown'; +import SortDropdown from '@/components/Filter/SortDropdown'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import DeviceDetailModal from '@/components/DeviceSearch/DeviceDetailModal'; +import CombinationSelectModal from '@/components/DeviceSearch/CombinationSelectModal'; +import CombinationDetailModal from '@/components/DeviceSearch/CombinationDetailModal'; +import SaveCompleteModal from '@/components/DeviceSearch/SaveCompleteModal'; +import SearchIcon from '@/assets/icons/search.svg?react'; +import FilterIcon from '@/assets/icons/filter.svg?react'; +import TopIcon from '@/assets/icons/top.svg?react'; + +import { + DEVICE_CATEGORIES, + SORT_OPTIONS, + PRICE_OPTIONS, +} from '@/constants/devices'; +import { mapSearchDeviceToProduct } from '@/utils/mapSearchDevice'; +import { useDeviceSearch } from '@/hooks/useDeviceSearch'; +import { useScrollState } from '@/hooks/useScrollState'; +import { useAddToCombination } from '@/hooks/useAddToCombination'; + +const DeviceSearchPage = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const selectedProductId = searchParams.get('productId'); + + // ๊ฒ€์ƒ‰/ํ•„ํ„ฐ ์ƒํƒœ + const search = useDeviceSearch(); + + const productGridRef = useRef(null); + const scroll = useScrollState(productGridRef); + + /* ์นดํ…Œ๊ณ ๋ฆฌ ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ */ + const handleCategoryClick = useCallback((categoryId: number) => { + search.setSelectedCategory( + search.selectedCategory === categoryId ? null : categoryId + ); + }, [search.selectedCategory, search.setSelectedCategory]); + + /* ์ œํ’ˆ ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ */ + const handleProductClick = useCallback((deviceId: number) => { + searchParams.set('productId', deviceId.toString()); + setSearchParams(searchParams); + }, [searchParams, setSearchParams]); + + /* ๋ณ€ํ™˜๋œ ์ œํ’ˆ ๋ฆฌ์ŠคํŠธ (useMemo๋กœ ์บ์‹ฑ) */ + const products = useMemo( + () => search.allDevices.map(device => ({ + product: mapSearchDeviceToProduct(device), + deviceId: device.deviceId, + })), + [search.allDevices] + ); + + /* ์„ ํƒ๋œ ์ œํ’ˆ ์ฐพ๊ธฐ */ + const selectedDevice = selectedProductId + ? search.allDevices.find(d => d.deviceId === Number(selectedProductId)) + : null; + const selectedProduct = selectedDevice ? mapSearchDeviceToProduct(selectedDevice) : null; + + // ์กฐํ•ฉ ๋‹ด๊ธฐ + ๋ชจ๋‹ฌ ์ƒํƒœ + const combo = useAddToCombination({ + selectedProductId, + selectedDeviceType: selectedDevice?.deviceType ?? null, + selectedDeviceName: selectedDevice?.name ?? null, + onCloseModal: () => { + // ์ƒˆ๋กœ์šด URLSearchParams ๊ฐ์ฒด ์ƒ์„ฑํ•˜์—ฌ React๊ฐ€ ๋ณ€๊ฒฝ ๊ฐ์ง€ํ•˜๋„๋ก ํ•จ + setSearchParams((prev) => { + const newParams = new URLSearchParams(prev); + newParams.delete('productId'); + return newParams; + }); + }, + }); + + /* ๋ชจ๋‹ฌ ์—ด๋ฆผ ์ƒํƒœ ํ™•์ธ ๋ฐ ์Šคํฌ๋กค ์ž ๊ธˆ + * ๊ธฐ๊ธฐ ์ƒ์„ธ ๋ชจ๋‹ฌ ๋˜๋Š” ์ €์žฅ ์™„๋ฃŒ ๋ชจ๋‹ฌ์ด ์—ด๋ ค์žˆ์„ ๋•Œ ์Šคํฌ๋กค ์ž ๊ธˆ */ + const isModalOpen = !!selectedProduct || combo.showSaveCompleteModal; + + /* ๋ชจ๋‹ฌ ์—ด๋ฆผ ์ƒํƒœ์— ๋”ฐ๋ฅธ ์Šคํฌ๋กค lock/unlock */ + useLayoutEffect(() => { + if (isModalOpen) { + document.documentElement.style.overflow = 'hidden'; + document.body.style.overflow = 'hidden'; + } else { + document.documentElement.style.overflow = ''; + document.body.style.overflow = ''; + } + }, [isModalOpen]); + + /* ESC ํ‚ค๋กœ ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ */ + useEffect(() => { + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isModalOpen) { + combo.handleCloseModal(); + } + }; + + if (isModalOpen) { + document.addEventListener('keydown', handleEscapeKey); + return () => { + document.removeEventListener('keydown', handleEscapeKey); + }; + } + }, [isModalOpen, combo]); + + return ( +
+ + + {/* Search Bar */} +
+
+ + search.setSearchQuery(e.target.value)} + className="flex-1 bg-transparent font-body-1-r text-gray-500 outline-none placeholder:text-gray-500" + /> +
+
+ + {/* Device Categories */} +
+
+ {DEVICE_CATEGORIES.map((category) => { + const { Icon } = category; + const isSelected = search.selectedCategory === category.id; + return ( + + ); + })} +
+
+ + {/* Divider */} +
+ + {/* Filter Section */} +
+ + {/* Filters */} +
+ {/* Filter Icon */} + + + {/* Price Filter */} +
+ search.setSelectedPrice(Array.isArray(value) ? value : [])} + multiple + /> +
+ + {/* Brand Filter */} +
+ search.setSelectedBrand(value as string | null)} + /> +
+
+ +
+ {/* Left side - Result count */} +
+

{search.allDevices.length}

+

๊ฐœ ๊ฒฐ๊ณผ

+
+ + {/* Right side - Sort dropdown */} + +
+
+ + {/* Product Grid */} +
+ {/* ์ดˆ๊ธฐ ๋กœ๋”ฉ: ๋ฐ์ดํ„ฐ๊ฐ€ ์—†๊ณ  ๋กœ๋”ฉ ์ค‘์ผ ๋•Œ๋งŒ ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ ํ‘œ์‹œ */} + {search.isSearchLoading && search.allDevices.length === 0 ? ( + + ) : search.isSearchError && search.allDevices.length === 0 ? ( +
+

๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.

+
+ ) : search.allDevices.length === 0 ? ( +
+

๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

+
+ ) : ( +
+ {products.map(({ product, deviceId }) => ( + handleProductClick(deviceId)} + /> + ))} +
+ )} + + {/* ๋ฌดํ•œ ์Šคํฌ๋กค ํŠธ๋ฆฌ๊ฑฐ */} +
+ + {/* ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ */} + {search.isFetchingNextPage && ( +
+

๋” ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+ )} +
+ + {/* Top Button - 3ํ–‰์ด ๋ณด์ผ ๋•Œ๋งŒ ํ‘œ์‹œ */} + {scroll.showTopButton && ( + + )} + + {/* Bottom Spacing */} +
+ {/* Device Detail Modal */} + {selectedProduct && !combo.showSaveCompleteModal && ( + <> + {/* Background Overlay - HomeIndicator๋ณด๋‹ค ๋†’๊ฒŒ ์„ค์ • */} + + ); +}; + +export default DeviceSearchPage; diff --git a/src/pages/lifestyle/LifestylePage.tsx b/src/pages/lifestyle/LifestylePage.tsx new file mode 100644 index 00000000..f608c2ac --- /dev/null +++ b/src/pages/lifestyle/LifestylePage.tsx @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ROTATION_MS, TRANSITION_MS, USER_INTERACTION_PAUSE_MS } from '@/constants/time'; +import LifestyleTag from '@/components/Lifestyle/LifestyleTag'; +import DeviceSummaryCard from '@/components/Lifestyle/DeviceSummaryCard'; +import { useAutoRotate } from '@/hooks/useAutoRotate'; +import { useCrossfadeImage } from '@/hooks/useCrossfadeImage'; +import { nextInArray } from '@/utils/nextInArray'; +import { useGetLifestyleDevice } from '@/apis/lifestyle/getLifestyleDevice'; +import type { LifestyleTagKey } from '@/types/lifestyle/lifestyle'; +import { + LIFESTYLE_TAGS, + type LifestyleLabel, + LIFESTYLE_TAG_IMAGE_MAP, + LIFESTYLE_LABEL_TO_TAGKEY, +} from '@/constants/lifestyle'; + +const LifestylePage = () => { + const [selectedLabel, setSelectedLabel] = useState(LIFESTYLE_TAGS[0]); + const [isAutoRotate, setIsAutoRotate] = useState(true); + const [isPaused, setIsPaused] = useState(false); + + const selectedTagKey = useMemo( + () => LIFESTYLE_LABEL_TO_TAGKEY[selectedLabel], + [selectedLabel] + ); + + const { data } = useGetLifestyleDevice(selectedTagKey); + const isSuccess = data?.success === true; + const lifestyleResult = isSuccess ? data?.result : null; + + const devices = useMemo(() => { + const list = lifestyleResult?.devices ?? []; + return [...list].sort((a, b) => a.slot - b.slot); + }, [lifestyleResult]); + + const targetSrc = useMemo(() => LIFESTYLE_TAG_IMAGE_MAP[selectedLabel], [selectedLabel]); + const { currentSrc, nextSrc, isNextVisible } = useCrossfadeImage(targetSrc, { + transitionMs: TRANSITION_MS, + }); + + const getNextTag = useCallback((prev: LifestyleLabel) => nextInArray(LIFESTYLE_TAGS, prev), []); + + const resumeTimerRef = useRef(null); + const resumeAtRef = useRef(null); + const isMountedRef = useRef(true); + + useAutoRotate({ + enabled: isAutoRotate && !isPaused, + intervalMs: ROTATION_MS, + setValue: setSelectedLabel, + getNext: getNextTag, + }); + + const handleClickTag = useCallback((label: LifestyleLabel) => { + const now = Date.now(); + if (resumeTimerRef.current !== null) { + clearTimeout(resumeTimerRef.current); + resumeTimerRef.current = null; + } + setSelectedLabel(label); + setIsAutoRotate(false); + + resumeAtRef.current = now + USER_INTERACTION_PAUSE_MS; + + resumeTimerRef.current = window.setTimeout(() => { + if (!isMountedRef.current) return; + if (Date.now() >= (resumeAtRef.current ?? 0)) { + setIsAutoRotate(true); + } + }, USER_INTERACTION_PAUSE_MS); + }, []); + + useEffect(() => { + return () => { + isMountedRef.current = false; + if (resumeTimerRef.current !== null) { + clearTimeout(resumeTimerRef.current); + resumeTimerRef.current = null; + } + }; + }, []); + + return ( +
+
+
+
+ {LIFESTYLE_TAGS.map((label) => ( + handleClickTag(label)} + /> + ))} +
+
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + > +
+ + + +
+ {selectedLabel} + {nextSrc && ( + {selectedLabel} + )} +
+
+
+
+ ); +}; + +export default LifestylePage; diff --git a/src/pages/my/MyCombinationDetailPage.tsx b/src/pages/my/MyCombinationDetailPage.tsx new file mode 100644 index 00000000..73c0cf45 --- /dev/null +++ b/src/pages/my/MyCombinationDetailPage.tsx @@ -0,0 +1,5 @@ +const MyCombinationDetailPage = () => { + return
MyCombinationDetailPage
; +}; + +export default MyCombinationDetailPage; diff --git a/src/pages/my/MyPage.tsx b/src/pages/my/MyPage.tsx new file mode 100644 index 00000000..e183daee --- /dev/null +++ b/src/pages/my/MyPage.tsx @@ -0,0 +1,351 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import GNB from '@/components/Home/GNB'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import MyPageSidebar from '@/components/MyPage/MyPageSidebar'; +import CombinationList from '@/components/MyPage/CombinationList'; +import DeviceDeleteModal from '@/components/MyPage/DeviceDeleteModal'; +import CombinationDeleteModal from '@/components/MyPage/CombinationDeleteModal'; +import SaveNameModal from '@/components/MyPage/SaveNameModal'; +import DeleteCompleteModal from '@/components/MyPage/DeleteCompleteModal'; +import SaveCompleteModal from '@/components/DeviceSearch/SaveCompleteModal'; +import TopIcon from '@/assets/icons/top.svg?react'; +import { useGetCombos } from '@/apis/combo/getCombos'; +import { useGetCombo } from '@/apis/combo/getComboId'; +import { usePutCombo } from '@/apis/combo/putCombos'; +import { useDeleteCombo } from '@/apis/combo/deleteCombo'; +import { usePostComboPin } from '@/apis/combo/postComboPin'; +import { useDeleteComboDevice } from '@/apis/combo/deleteComboDevice'; +import { useComboEvaluation } from '@/apis/combo/getComboEvaluation'; +import { useAuth } from '@/hooks/useAuth'; +import { useCombinationModals } from '@/hooks/useCombinationModals'; +import { useDeviceSelection } from '@/hooks/useDeviceSelection'; +import { useCombinationSort } from '@/hooks/useCombinationSort'; +import { useCombinationEdit } from '@/hooks/useCombinationEdit'; +import { useClickOutside } from '@/hooks/useClickOutside'; +import { useModalScrollLock } from '@/hooks/useModalScrollLock'; +import { useMyPageScroll } from '@/hooks/useMyPageScroll'; +import { mapEvaluationToUI } from '@/utils/mapEvaluationToUI'; +import type { LifestyleKey } from '@/constants/evaluation/lifestyle'; + +const MyPage = () => { + const navigate = useNavigate(); + + // ์ƒํƒœ ๊ด€๋ฆฌ + const [columns, setColumns] = useState<3 | 4>(4); + const [openMenuIndex, setOpenMenuIndex] = useState(null); + const [detailViewComboId, setDetailViewComboId] = useState(null); + const [savedScrollPosition, setSavedScrollPosition] = useState(0); + + const menuRef = useRef(null!); + const sidebarContentRef = useRef(null!); + const combinationListRef = useRef(null!); + + // API ํ˜ธ์ถœ + const { data: combos = [], isLoading, isError } = useGetCombos(); + const { data: comboDetail } = useGetCombo(detailViewComboId); + const { mutate: updateCombo, isPending: isUpdating } = usePutCombo(); + const { mutate: deleteCombo, isPending: isDeleting } = useDeleteCombo(); + const { mutate: togglePin } = usePostComboPin(); + const { mutate: deleteDevice, isPending: isDeletingDevice } = useDeleteComboDevice(); + const { user: userProfile, isAuthLoading } = useAuth(); + const { data: evaluation, isLoading: isEvaluationLoading } = useComboEvaluation( + detailViewComboId ?? undefined + ); + + // ์ปค์Šคํ…€ ํ›… + const modals = useCombinationModals(); + const deviceSelection = useDeviceSelection(); + const { sortOption, setSortOption, sortedCombos } = useCombinationSort(combos); + const combinationEdit = useCombinationEdit(combos); + + // ์œ ์ € ๋ผ์ดํ”„์Šคํƒ€์ผ ํƒœ๊ทธ ๋ณ€ํ™˜ + const lifestyleKey = useMemo(() => { + const raw = userProfile?.lifestyleList?.[0]; + if (!raw) return undefined; + return raw.replace(/^#\s*/, '') as LifestyleKey; + }, [userProfile]); + + // ํ‰๊ฐ€ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ + const evaluationCards = useMemo(() => { + if (!evaluation) return null; + return mapEvaluationToUI(evaluation, lifestyleKey); + }, [evaluation, lifestyleKey]); + + // ์Šคํฌ๋กค ๊ฐ์ง€ (ํ•˜๋‹จ ๊ทธ๋ผ๋ฐ์ด์…˜์šฉ + Top ๋ฒ„ํŠผ์šฉ) + const { isAtBottom, showTopButton } = useMyPageScroll(combinationListRef); + + // ๋ธŒ๋ ˆ์ดํฌํฌ์ธํŠธ ๊ฐ์ง€ (์นผ๋Ÿผ ์ˆ˜ ๋ฐ˜์‘ํ˜•) + useEffect(() => { + const mediaQuery = window.matchMedia('(min-width: 1536px)'); + const handleChange = (e: MediaQueryListEvent | MediaQueryList) => { + setColumns(e.matches ? 4 : 3); + }; + handleChange(mediaQuery); + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + // ํŽ˜์ด์ง€ ๋งˆ์šดํŠธ ์‹œ sessionStorage์—์„œ ์Šคํฌ๋กค ์œ„์น˜ ๋ณต์› + useEffect(() => { + const savedScroll = sessionStorage.getItem('mypage-scroll'); + if (savedScroll) { + const scrollPosition = parseInt(savedScroll, 10); + // ์•ฝ๊ฐ„์˜ ์ง€์—ฐ์„ ๋‘์–ด DOM์ด ์™„์ „ํžˆ ๋ Œ๋”๋ง๋œ ํ›„ ์Šคํฌ๋กค + setTimeout(() => { + window.scrollTo(0, scrollPosition); + }, 0); + sessionStorage.removeItem('mypage-scroll'); + } + }, []); + + // ๋“œ๋กญ๋‹ค์šด ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ + const handleClickOutside = useCallback(() => setOpenMenuIndex(null), []); + useClickOutside(menuRef, handleClickOutside); + + // ๋ชจ๋‹ฌ ์—ด๋ฆด ๋•Œ ๋ฐฐ๊ฒฝ ์Šคํฌ๋กค ๋ฐฉ์ง€ + const isAnyModalOpen = + modals.showDeleteModal || + modals.showCombinationDeleteModal || + modals.showSaveModal || + modals.showDeleteSuccessModal || + modals.showSaveSuccessModal; + useModalScrollLock(isAnyModalOpen); + + // ์ž์„ธํžˆ๋ณด๊ธฐ ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ + const handleDetailView = useCallback((comboId: number) => { + setSavedScrollPosition(window.scrollY); + setDetailViewComboId(comboId); + setOpenMenuIndex(null); + deviceSelection.clearSelection(); + window.scrollTo(0, 0); + }, [deviceSelection]); + + // ๋’ค๋กœ๊ฐ€๊ธฐ ํ•ธ๋“ค๋Ÿฌ + const handleBackToNormal = useCallback(() => { + setDetailViewComboId(null); + deviceSelection.clearSelection(); + window.scrollTo(0, savedScrollPosition); + }, [deviceSelection, savedScrollPosition]); + + // ์„ ํƒ๋œ ๊ธฐ๊ธฐ ์‚ญ์ œ ํ•ธ๋“ค๋Ÿฌ + const handleDeleteDevices = useCallback(async () => { + if (!detailViewComboId || deviceSelection.selectedDevices.length === 0) return; + + try { + for (const deviceId of deviceSelection.selectedDevices) { + await new Promise((resolve, reject) => { + deleteDevice( + { comboId: detailViewComboId, deviceId }, + { + onSuccess: () => resolve(), + onError: (error) => reject(error), + } + ); + }); + } + + deviceSelection.clearSelection(); + modals.closeDeleteModal(); + + setTimeout(() => { + modals.openDeleteSuccessModal(); + }, 300); + } catch { + // ๊ธฐ๊ธฐ ์‚ญ์ œ ์‹คํŒจ ์‹œ ์กฐ์šฉํžˆ ์ฒ˜๋ฆฌ + } + }, [detailViewComboId, deviceSelection, deleteDevice, modals]); + + // ํœด์ง€ํ†ต ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ + const handleTrashClick = useCallback(() => { + if (deviceSelection.selectedDevices.length > 0) { + modals.openDeleteModal(); + } + }, [deviceSelection.selectedDevices.length, modals]); + + // ์กฐํ•ฉ ์‚ญ์ œ ํ•ธ๋“ค๋Ÿฌ + const handleDeleteCombination = useCallback(() => { + if (modals.deleteTargetComboId === null) return; + + deleteCombo(modals.deleteTargetComboId, { + onSuccess: () => { + modals.closeCombinationDeleteModal(); + + if (detailViewComboId !== null) { + setDetailViewComboId(null); + deviceSelection.clearSelection(); + } + + setTimeout(() => { + modals.openDeleteSuccessModal(); + }, 300); + }, + }); + }, [modals, deleteCombo, detailViewComboId, deviceSelection]); + + // Pin ํ† ๊ธ€ ํ•ธ๋“ค๋Ÿฌ + const handleTogglePin = useCallback((e: React.MouseEvent, comboId: number) => { + e.stopPropagation(); + togglePin(comboId); + }, [togglePin]); + + // ์กฐํ•ฉ๋ช… ์ €์žฅ ํ•ธ๋“ค๋Ÿฌ + const handleSaveCombinationName = useCallback(() => { + if (combinationEdit.editingComboId === null) return; + + const finalError = combinationEdit.validateComboName(combinationEdit.editingCombinationName); + if (finalError) { + combinationEdit.setComboNameError(finalError); + return; + } + + const trimmedName = combinationEdit.editingCombinationName.trim(); + + updateCombo( + { comboId: combinationEdit.editingComboId, comboName: trimmedName }, + { + onSuccess: () => { + modals.closeSaveModal(); + combinationEdit.stopEditing(); + + setTimeout(() => { + modals.openSaveSuccessModal(); + }, 300); + }, + } + ); + }, [combinationEdit, updateCombo, modals]); + + // ๋งจ ์œ„๋กœ ์Šคํฌ๋กค + const handleScrollToTop = useCallback(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + + // ์ž์„ธํžˆ๋ณด๊ธฐ์—์„œ + ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ: ์ด๋ฏธ ์ €์žฅ๋œ ์Šคํฌ๋กค ์œ„์น˜๋ฅผ sessionStorage์— ์ €์žฅ + const handleSaveScrollBeforeNavigate = useCallback(() => { + sessionStorage.setItem('mypage-scroll', savedScrollPosition.toString()); + }, [savedScrollPosition]); + + return ( +
+ + +
+ {/* ์ขŒ์ธก ์‚ฌ์ด๋“œ๋ฐ” */} + + + {/* ์šฐ์ธก ๋ฉ”์ธ ์ฝ˜ํ…์ธ  */} +
+ {/* ํ—ค๋”: ๋‚ด ์กฐํ•ฉ + ์ƒˆ ์กฐํ•ฉ ์ถ”๊ฐ€ํ•˜๊ธฐ / ์กฐํ•ฉ ์‚ญ์ œํ•˜๊ธฐ */} +
+

๋‚ด ์กฐํ•ฉ

+ {detailViewComboId !== null ? ( + + ) : ( + navigate('/combination/create')} + className="w-280 bg-blue-600 hover:bg-blue-500" + /> + )} +
+ + {/* ์กฐํ•ฉ ์นด๋“œ ๋ชฉ๋ก */} + + + {/* Top Button */} + {showTopButton && ( + + )} + + {/* ํ•˜๋‹จ ์—ฌ๋ฐฑ */} +
+
+
+ + {/* ๋ชจ๋‹ฌ๋“ค */} + {modals.showDeleteModal && ( + + )} + + {modals.showCombinationDeleteModal && modals.deleteTargetComboId !== null && (() => { + const targetCombo = sortedCombos.find((c) => c.comboId === modals.deleteTargetComboId); + if (!targetCombo) return null; + return ( + + ); + })()} + + {modals.showSaveModal && ( + + )} + + {modals.showDeleteSuccessModal && ( + + )} + + {modals.showSaveSuccessModal && ( + + )} +
+ ); +}; + +export default MyPage; diff --git a/src/pages/my/MyTrashPage.tsx b/src/pages/my/MyTrashPage.tsx new file mode 100644 index 00000000..7f92658c --- /dev/null +++ b/src/pages/my/MyTrashPage.tsx @@ -0,0 +1,5 @@ +const MyTrashPage = () => { + return
MyTrashPage
; +}; + +export default MyTrashPage; diff --git a/src/pages/my/settings/PasswordEditPage.tsx b/src/pages/my/settings/PasswordEditPage.tsx new file mode 100644 index 00000000..34a216dd --- /dev/null +++ b/src/pages/my/settings/PasswordEditPage.tsx @@ -0,0 +1,142 @@ +import { useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { AxiosError } from 'axios'; +import BackIcon from '@/assets/icons/back_gray.svg?react'; +import OldPasswordInputSection from '@/components/Setting/OldPasswordInputSection'; +import NewPasswordInputSection from '@/components/Setting/NewPasswordInputSection'; +import PasswordConfirmInputSection from '@/components/Setting/PasswordConfirmInputSection'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import { validateNewPassword, validatePasswordConfirm } from '@/utils/validatePassword'; +import { usePutEditPassword } from '@/apis/mypage/putEditPassword'; + +const PasswordEditPage = () => { + const navigate = useNavigate(); + const { mutate: putEditPassword, isPending } = usePutEditPassword(); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [oldPasswordServerError, setOldPasswordServerError] = useState(); + const [newPasswordServerError, setNewPasswordServerError] = useState(); + const [confirmServerError, setConfirmServerError] = useState(); + const [confirmClientError, setConfirmClientError] = useState(); + const newPasswordError = useMemo(() => validateNewPassword(newPassword), [newPassword]); + const filled = useMemo(() => { + return oldPassword.length > 0 && newPassword.length > 0 && confirmPassword.length > 0; + }, [oldPassword, newPassword, confirmPassword]); + const hasAnyVisibleError = useMemo(() => { + const hasOld = !!oldPasswordServerError; + const hasNew = !!newPasswordError || !!newPasswordServerError; + const hasConfirm = !!confirmServerError || !!confirmClientError; + return hasOld || hasNew || hasConfirm; + }, [ + oldPasswordServerError, + newPasswordError, + newPasswordServerError, + confirmServerError, + confirmClientError, + ]); + + const canSubmit = useMemo(() => { + if (!filled) return false; + if (newPasswordError) return false; + if (hasAnyVisibleError) return false; + return true; + }, [filled, newPasswordError, hasAnyVisibleError]); + + const handleOldPasswordChange = (next: string) => { + setOldPassword(next); + if (oldPasswordServerError) setOldPasswordServerError(undefined); + }; + + const handleNewPasswordChange = (next: string) => { + setNewPassword(next); + if (newPasswordServerError) setNewPasswordServerError(undefined); + if (confirmServerError) setConfirmServerError(undefined); + if (confirmClientError) setConfirmClientError(undefined); + }; + + const handleConfirmChange = (next: string) => { + setConfirmPassword(next); + if (confirmServerError) setConfirmServerError(undefined); + if (confirmClientError) setConfirmClientError(undefined); + }; + + const handleSave = () => { + setOldPasswordServerError(undefined); + setNewPasswordServerError(undefined); + setConfirmServerError(undefined); + const confirmErrNow = validatePasswordConfirm(newPassword, confirmPassword); + setConfirmClientError(confirmErrNow); + if (newPasswordError || confirmErrNow) return; + + putEditPassword( + { + oldPassword, + newPassword, + newPasswordConfirm: confirmPassword, + }, + { + onSuccess: () => { + navigate('/my'); + }, + onError: (error) => { + const axiosError = error as AxiosError; + const code: string | undefined = axiosError.response?.data?.code; + const message: string | undefined = axiosError.response?.data?.message; + + if (code === 'USER_4006') { + setOldPasswordServerError(message); + return; + } + if (code === 'USER_4007') { + setConfirmServerError(message); + return; + } + if (code === 'USER_4008') { + setNewPasswordServerError(message); + return; + } + }, + } + ); + }; + + return ( +
+
+ navigate('/my/settings/profile')} + /> +

๋น„๋ฐ€๋ฒˆํ˜ธ ์ˆ˜์ •

+
+
+ + + +
+
+ +
+
+ ); +}; + +export default PasswordEditPage; diff --git a/src/pages/my/settings/ProfileEditPage.tsx b/src/pages/my/settings/ProfileEditPage.tsx new file mode 100644 index 00000000..37d36f43 --- /dev/null +++ b/src/pages/my/settings/ProfileEditPage.tsx @@ -0,0 +1,113 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '@/hooks/useAuth'; +import { usePatchEditProfile } from '@/apis/mypage/patchEditProfile'; +import NicknameEditSection from '@/components/Setting/NicknameEditSection'; +import EmailSection from '@/components/Setting/EmailSection'; +import PasswordSettingSection from '@/components/Setting/PasswordSettingSection'; +import LifestyleSelectSection from '@/components/Setting/LifestyleSelectSection'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import { validateNickname } from '@/utils/validateNickname'; +import BackIcon from '@/assets/icons/back_gray.svg?react'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import { LIFESTYLE_DISPLAY_TAGS, type LifestyleDisplayTag } from '@/constants/lifestyle'; + +const ProfileEditPage = () => { + const navigate = useNavigate(); + const { user, isAuthLoading, refetchUserProfile } = useAuth(); + const { mutate: patchProfile, isPending } = usePatchEditProfile(); + const serverLifestyle = useMemo(() => { + const raw = user?.lifestyleList ?? []; + return raw.filter((t): t is LifestyleDisplayTag => + LIFESTYLE_DISPLAY_TAGS.includes(t as LifestyleDisplayTag) + ); + }, [user]); + + const initialNickname = user?.username ?? '000'; + const initialEmail = user?.email ?? 'example@devicelife.com'; + const initialLifestyles = serverLifestyle; + const authProvider = user?.authProvider ?? 'GENERAL'; + const [nickname, setNickname] = useState(initialNickname); + const [lifestyles, setLifestyles] = useState(initialLifestyles); + const normalizeLifestyleList = (arr: LifestyleDisplayTag[]) => [...arr].sort().join(','); + + const handleNicknameChange = (v: string) => { + isEditingRef.current = true; + setNickname(v); + }; + + const handleLifestylesChange = (v: LifestyleDisplayTag[]) => { + isEditingRef.current = true; + setLifestyles(v); + }; + + const isEditingRef = useRef(false); + + const payload = useMemo(() => { + const isLifestyleChanged = + normalizeLifestyleList(lifestyles) !== normalizeLifestyleList(initialLifestyles); + + return { + username: nickname !== initialNickname ? nickname : null, + email: null, + lifestyleList: isLifestyleChanged ? lifestyles : null, + }; + }, [nickname, lifestyles, initialNickname, initialLifestyles]); + + useEffect(() => { + if (!user) return; + if (isEditingRef.current) return; + setNickname(user.username ?? '000'); + setLifestyles(serverLifestyle); + }, [user, serverLifestyle]); + + const nicknameError = validateNickname(nickname); + const isLifestyleValid = lifestyles.length === 1; + const isDirty = useMemo(() => { + if (nickname !== initialNickname) return true; + const cur = normalizeLifestyleList(lifestyles); + const init = normalizeLifestyleList(initialLifestyles); + return cur !== init; + }, [nickname, lifestyles, initialNickname, initialLifestyles]); + + const handleSave = () => { + patchProfile(payload, { + onSuccess: async () => { + isEditingRef.current = false; + await refetchUserProfile(); + navigate('/my'); + }, + }); + }; + + if (isAuthLoading) return ; + + return ( +
+
+ navigate('/my')} /> +

ํ”„๋กœํ•„ ์ˆ˜์ •

+
+
+ + + {(authProvider === 'GENERAL' || authProvider === 'HYBRID') && } + +
+
+ +
+
+ ); +}; + +export default ProfileEditPage; diff --git a/src/pages/onboarding/OnboardingCombinationPage.tsx b/src/pages/onboarding/OnboardingCombinationPage.tsx new file mode 100644 index 00000000..68d9345f --- /dev/null +++ b/src/pages/onboarding/OnboardingCombinationPage.tsx @@ -0,0 +1,143 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate, Navigate } from 'react-router-dom'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import StepIndicator from '@/components/Auth/Indicator/StepIndicator'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import { + onboardingCombinationSchema, + type OnboardingCombinationFormData, +} from '@/schemas/authSchema'; +import { ROUTES } from '@/constants/routes'; +import { usePostCreateCombination } from '@/apis/combo/postCreateCombination'; +import { useAuth } from '@/hooks/useAuth'; + +const OnboardingCombinationPage = () => { + const [step, setStep] = useState(1); + const [combinationName, setCombinationName] = useState(''); + const navigate = useNavigate(); + const { mutateAsync: createCombo, isPending } = usePostCreateCombination(); + const { user, isAuthLoading } = useAuth(); + + const { + register, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(onboardingCombinationSchema), + mode: 'onChange', + }); + + // ํ”„๋กœํ•„ ์ดˆ๊ธฐ ๋กœ๋”ฉ ์ค‘์ด๋ฉด ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ ํ‘œ์‹œ + if (isAuthLoading) { + return ; + } + + // ๋ผ์ดํ”„์Šคํƒ€์ผ ํƒœ๊ทธ๊ฐ€ ์—†์œผ๋ฉด ๋ผ์ดํ”„์Šคํƒ€์ผ ์„ ํƒ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + if (!user?.lifestyleList?.length) { + return ; + } + + const onSubmit = (data: OnboardingCombinationFormData) => { + setCombinationName(data.combinationName.trim()); + setStep(2); + }; + + const handleSelectCombination = async () => { + // isPending์ผ ๋•Œ๋Š” ์ค‘๋ณต ์š”์ฒญ ๋ฐฉ์ง€ + if (isPending) return; + + try { + await createCombo({ comboName: combinationName }); + navigate(ROUTES.onboarding.complete, { replace: true }); + } catch (error) { + alert('์กฐํ•ฉ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); + } + }; + + return ( +
+ {/* Step 1: ์กฐํ•ฉ๋ช… ์ž…๋ ฅ */} + {step === 1 && ( +
+ {/* ์ƒ๋‹จ ์˜์—ญ (ํŽ˜์ด์ง€๋„ค์ด์…˜ + ํ…์ŠคํŠธ) */} +
+ {/* ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ธ๋””์ผ€์ดํ„ฐ */} + + {/* ๋ฉ”์ธ ํƒ€์ดํ‹€ */} +

+ ๋‚˜์˜ ์ฒซ ๊ธฐ๊ธฐ ์กฐํ•ฉ์„ ์ƒ์„ฑํ•ด ์ฃผ์„ธ์š”. +

+ {/* ์กฐํ•ฉ๋ช… ์˜ˆ์‹œ ํ…์ŠคํŠธ */} +

+ ์กฐํ•ฉ๋ช… ์˜ˆ์‹œ: iPhone 15Pro ์ค‘์‹ฌ ์กฐํ•ฉ / ์‚ฌ๋ฌด์‹ค ์„ธํŒ… +

+
+ + {/* ์ž…๋ ฅ์ฐฝ + ๋ฒ„ํŠผ ์˜์—ญ */} +
+
+
+ +
+ +
+ {/* ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ */} + {errors.combinationName && ( +

+ {errors.combinationName.message} +

+ )} +
+
+ )} + + {/* Step 2: ์กฐํ•ฉ๋ช… ํ™•์ธ */} + {step === 2 && ( +
+
+ {/* ์กฐํ•ฉ๋ช… ํ‘œ์‹œ ๋ฐ•์Šค (double border) */} +
+ {/* Outer border */} +
+ {/* Inner box */} +
+ {combinationName} +
+
+ + {/* ์•ˆ๋‚ด ๋ฌธ๊ตฌ */} +

+ ์ด์ œ ๊ธฐ๊ธฐ๊ฒ€์ƒ‰ ์ฐฝ์—์„œ ์›ํ•˜๋Š” ๊ธฐ๊ธฐ๋“ค์„ ๊ณจ๋ผ ๋‚ด๊ฐ€ ๋งŒ๋“  ์กฐํ•ฉ์— ๋‹ด์•„๋ณด์„ธ์š”! +

+
+ + {/* ์™„๋ฃŒ ๋ฒ„ํŠผ */} + +
+ )} +
+ ); +}; + +export default OnboardingCombinationPage; diff --git a/src/pages/onboarding/OnboardingCompletePage.tsx b/src/pages/onboarding/OnboardingCompletePage.tsx new file mode 100644 index 00000000..bcc62050 --- /dev/null +++ b/src/pages/onboarding/OnboardingCompletePage.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, Navigate } from 'react-router-dom'; +import OnboardingLines from '@/assets/icons/onboarding_lines.svg?react'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import { useAuth } from '@/hooks/useAuth'; +import { usePostOnboardingComplete } from '@/apis/onboarding/postComplete'; +import { ROUTES } from '@/constants/routes'; + +const OnboardingCompletePage = () => { + const navigate = useNavigate(); + const { user, isAuthLoading } = useAuth(); + const { mutateAsync: completeOnboarding } = usePostOnboardingComplete(); + const [isCompleted, setIsCompleted] = useState(false); + const userName = user?.username ?? ''; + + // ๊ฒ€์ฆ ์กฐ๊ฑด(์˜จ๋ณด๋”ฉ ๊ณผ์ • ์Šคํ‚ตํ•˜๊ณ  ๋ฐ”๋กœ ๋“ค์–ด์˜ค๋Š” ์œ ์ € ๋Œ€๋น„) + const isAlreadyCompleted = user?.isOnboardingCompleted; + const hasNoLifestyleTags = !user?.lifestyleList?.length; + const shouldSkipApiCall = isAuthLoading || isAlreadyCompleted || hasNoLifestyleTags; + + // ํŽ˜์ด์ง€ ์ง„์ž… ์‹œ ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ API ํ˜ธ์ถœ (๊ฒ€์ฆ ํ†ต๊ณผ ์‹œ์—๋งŒ) + useEffect(() => { + if (shouldSkipApiCall) return; + + const complete = async () => { + try { + await completeOnboarding(); + setIsCompleted(true); + } catch (error) { + alert('์˜จ๋ณด๋”ฉ ์™„๋ฃŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); + navigate(ROUTES.onboarding.lifestyle, { replace: true }); + } + }; + complete(); + }, [shouldSkipApiCall, navigate]); + + // ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ ํ›„ 5์ดˆ ๋’ค ์ถ”์ฒœ ํŽ˜์ด์ง€๋กœ ์ด๋™ + useEffect(() => { + if (!isCompleted) return; + + const timer = setTimeout(() => { + navigate(ROUTES.recommendation, { replace: true }); + }, 4000); + + return () => clearTimeout(timer); + }, [isCompleted, navigate]); + + // ํ”„๋กœํ•„ ๋กœ๋”ฉ ์ค‘์ด๋ฉด ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ ํ‘œ์‹œ + if (isAuthLoading) { + return ; + } + + // ์ด๋ฏธ ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ๋œ ๊ฒฝ์šฐ โ†’ ์ถ”์ฒœ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ (์ค‘๋ณต ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ API ํ˜ธ์ถœ ๋ฐฉ์ง€) + // ๋‹จ, ์ •์ƒ์ ์œผ๋กœ ํ˜„์žฌ ํŽ˜์ด์ง€์—์„œ ์™„๋ฃŒํ•œ ๊ฒฝ์šฐ(isCompleted)๋Š” 4์ดˆ ํƒ€์ด๋จธ๋ฅผ ๊ธฐ๋‹ค๋ ค์•ผ ํ•จ + if (isAlreadyCompleted && !isCompleted) { + return ; + } + + // ๋ผ์ดํ”„์Šคํƒ€์ผ ํƒœ๊ทธ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ โ†’ ๋ผ์ดํ”„์Šคํƒ€์ผ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + if (hasNoLifestyleTags) { + return ; + } + + // ํ•œ๊ธ€๊ณผ ์˜๋ฌธ ๊ธธ์ด ์ฒดํฌ ํ•จ์ˆ˜ + const checkNameLength = (name: string) => { + let koreanCount = 0; + let englishCount = 0; + + for (const char of name) { + // ํ•œ๊ธ€ ์œ ๋‹ˆ์ฝ”๋“œ ๋ฒ”์œ„: AC00-D7A3 + if (char >= '\uAC00' && char <= '\uD7A3') { + koreanCount++; + } else if (/[a-zA-Z]/.test(char)) { + englishCount++; + } + } + + return koreanCount >= 5 || englishCount >= 7; + }; + + const shouldUseTwoLines = checkNameLength(userName); + + return ( +
+ {/* ํšŒ๋กœ ์„ ๋“ค (๋’ค์— ๋ฐฐ์น˜, ์ค‘์•™ ์ •๋ ฌ) */} +
+ +
+ + {/* ์›ํ˜• ์ปจํ…Œ์ด๋„ˆ (์•ž์— ๋ฐฐ์น˜) */} +
+ {/* ํ™˜์˜ ๋ฉ”์‹œ์ง€ */} + {shouldUseTwoLines ? ( +
+ {userName} ๋‹˜, + ์–ด์„œ์˜ค์„ธ์š”! +
+ ) : ( +
+ {userName} ๋‹˜, + ์–ด์„œ์˜ค์„ธ์š”! +
+ )} +
+
+ ); +}; + +export default OnboardingCompletePage; diff --git a/src/pages/onboarding/OnboardingLifestylePage.tsx b/src/pages/onboarding/OnboardingLifestylePage.tsx new file mode 100644 index 00000000..fa17fc27 --- /dev/null +++ b/src/pages/onboarding/OnboardingLifestylePage.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import OnboardingLifestyleTag from '@/components/Lifestyle/OnboardingLifestyleTag'; +import StepIndicator from '@/components/Auth/Indicator/StepIndicator'; +import { ROUTES } from '@/constants/routes'; +import { useGroupedTags } from '@/hooks/useGroupedTags'; +import { usePostUserTags } from '@/apis/tag/postTags'; + +const OnboardingLifestylePage = () => { + const navigate = useNavigate(); + const { tags } = useGroupedTags(); + const { mutateAsync: saveTags, isPending } = usePostUserTags(); + const [selectedInterest, setSelectedInterest] = useState([]); + const [selectedLifestyle, setSelectedLifestyle] = useState([]); + const [selectedBrand, setSelectedBrand] = useState([]); + + const toggleSelection = ( + tagId: number, + selectedItems: number[], + setSelectedItems: React.Dispatch> + ) => { + if (selectedItems.includes(tagId)) { + setSelectedItems([]); // ๊ฐ™์€ ํ•ญ๋ชฉ ๋‹ค์‹œ ํด๋ฆญ ์‹œ ์„ ํƒ ํ•ด์ œ + } else { + setSelectedItems([tagId]); // ์ƒˆ๋กœ์šด ํ•ญ๋ชฉ์œผ๋กœ ๋Œ€์ฒด (๋‹จ์ผ ์„ ํƒ) + } + }; + + // ๊ฐ ์„น์…˜๋ณ„ ์ตœ์†Œ 1๊ฐœ์”ฉ ์„ ํƒ ์—ฌ๋ถ€ ํ™•์ธ + const isAllSelected = + selectedInterest.length > 0 && + selectedLifestyle.length > 0 && + selectedBrand.length > 0; + + const handleNext = async () => { + const allSelectedTagIds = [...selectedInterest, ...selectedLifestyle, ...selectedBrand]; + + try { + await saveTags({ tagIds: allSelectedTagIds }); + navigate(ROUTES.onboarding.combination, { replace: true }); + } catch (error) { + alert('ํƒœ๊ทธ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); + } + }; + + return ( +
+ {/* ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ */} +
+ {/* ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ธ๋””์ผ€์ดํ„ฐ */} + + + {/* ์ฝ˜ํ…์ธ  ์˜์—ญ */} +
+ {/* ํƒ€์ดํ‹€ ์˜์—ญ */} +
+ {/* ๋ฉ”์ธ ํƒ€์ดํ‹€ */} +

+ ํšŒ์›๋‹˜์˜ ๋ผ์ดํ”„ ์Šคํƒ€์ผ์„ ๊ณจ๋ผ์ฃผ์„ธ์š” +

+ {/* ์„œ๋ธŒ ํƒ€์ดํ‹€ */} +

+ AI๊ฐ€ ํšŒ์›๋‹˜์˜ ์กฐํ•ฉ์„ ํ‰๊ฐ€ํ•  ๋•Œ ์ด ๊ธฐ์ค€์„ ์ฐธ๊ณ ํ•ฉ๋‹ˆ๋‹ค. (๋ฌธํ•ญ๋ณ„ ํƒ1) +

+
+ + {/* 3๊ฐœ ์ปฌ๋Ÿผ ์˜์—ญ */} +
+ {/* ์™ผ์ชฝ ์ปฌ๋Ÿผ: ์ค‘์š”ํ•˜๊ฒŒ ์ƒ๊ฐํ•˜๋Š” ๊ฒƒ */} +
+

+ ์ค‘์š”ํ•˜๊ฒŒ ์ƒ๊ฐํ•˜๋Š” ๊ฒƒ์€? +

+
+ {tags.interest.map((tag) => ( + + toggleSelection(tag.tagId, selectedInterest, setSelectedInterest) + } + className="w-full h-50" + /> + ))} +
+
+ + {/* ์ค‘๊ฐ„ ์ปฌ๋Ÿผ: ์ฃผ๋œ ์šฉ๋„ */} +
+

๋‚˜์˜ ์ฃผ๋œ ์šฉ๋„๋Š”?

+
+ {tags.lifestyle.map((tag) => ( + toggleSelection(tag.tagId, selectedLifestyle, setSelectedLifestyle)} + className="w-264 h-88" + /> + ))} +
+
+ + {/* ์˜ค๋ฅธ์ชฝ ์ปฌ๋Ÿผ: ์„ ํ˜ธ ๋ธŒ๋žœ๋“œ */} +
+

+ ์„ ํ˜ธํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋Š”? +

+
+ {tags.brand.map((tag) => ( + toggleSelection(tag.tagId, selectedBrand, setSelectedBrand)} + className="w-full h-50" + /> + ))} +
+
+
+ + {/* ๋‹ค์Œ ๋ฒ„ํŠผ */} + +
+
+
+ ); +}; + +export default OnboardingLifestylePage; diff --git a/src/pages/onboarding/OnboardingRecommendationPage.tsx b/src/pages/onboarding/OnboardingRecommendationPage.tsx new file mode 100644 index 00000000..e30ba8cf --- /dev/null +++ b/src/pages/onboarding/OnboardingRecommendationPage.tsx @@ -0,0 +1,164 @@ +import { useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import clsx from 'clsx'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import RecentlyViewedCard from '@/components/RecentlyViewed/RecentlyViewedCard'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import type { RecentlyViewedDevice } from '@/types/recentlyViewed/recentlyViewed'; +import type { LifestyleTagKey } from '@/types/lifestyle/lifestyle'; +import { useAuth } from '@/hooks/useAuth'; +import { useGroupedTags } from '@/hooks/useGroupedTags'; +import { useGetLifestyleDevice } from '@/apis/lifestyle/getLifestyleDevice'; +import { useGetCombos } from '@/apis/combo/getCombos'; +import { usePostComboDevice } from '@/apis/combo/postComboDevices'; +import { parseApiError } from '@/utils/error'; +import { ROUTES } from '@/constants/routes'; + +const OnboardingRecommendationPage = () => { + const navigate = useNavigate(); + const { user } = useAuth(); + const { tags } = useGroupedTags(); + + // ๋ผ์ดํ”„์Šคํƒ€์ผ ํƒœ๊ทธ ๋ผ๋ฒจ (์œ ์ € ํ”„๋กœํ•„์—์„œ ๊ฐ€์ ธ์˜จ ๊ฐ’) + const lifestyleTagLabel = user?.lifestyleList?.[0] || ''; + + // ํƒœ๊ทธ ๋ชฉ๋ก์—์„œ tagLabel๋กœ ๋งค์นญํ•˜์—ฌ tagKey ์ถ”์ถœ (API ํŒŒ๋ผ๋ฏธํ„ฐ์šฉ) + const lifestyleTagKey = useMemo(() => { + const matched = tags.lifestyle.find((t) => t.tagLabel === lifestyleTagLabel); + return matched?.tagKey || ''; + }, [tags.lifestyle, lifestyleTagLabel]); + + // ์ถ”์ฒœ ๊ธฐ๊ธฐ ์กฐํšŒ + const { data: lifestyleData, isLoading: isDevicesLoading } = useGetLifestyleDevice(lifestyleTagKey as LifestyleTagKey); + + // ์กฐํ•ฉ ๋ชฉ๋ก ์กฐํšŒ (์˜จ๋ณด๋”ฉ์—์„œ ์ƒ์„ฑํ•œ ์กฐํ•ฉ์˜ comboId๋ฅผ ๊ฐ€์ ธ์˜ค๊ธฐ ์œ„ํ•จ) + const { data: combos } = useGetCombos(); + const comboId = combos?.[0]?.comboId ?? null; + + // ์กฐํ•ฉ์— ๊ธฐ๊ธฐ ์ถ”๊ฐ€ mutation + const { mutateAsync: addDevice } = usePostComboDevice(); + const [isAdding, setIsAdding] = useState(false); + + // ์„ ํƒ๋œ ๊ธฐ๊ธฐ ID (๋‹จ์ผ ์„ ํƒ) + const [selectedDeviceId, setSelectedDeviceId] = useState(null); + + const selectDevice = (deviceId: number) => { + setSelectedDeviceId(deviceId); + }; + + // ์œ ์ €๋ช… + const userName = user?.username || ''; + + // ํƒ€์ดํ‹€ ํ‘œ์‹œ ์—ฌ๋ถ€ + const hasTitleData = lifestyleTagLabel && userName; + + // API ์‘๋‹ต์„ RecentlyViewedDevice ํ˜•ํƒœ๋กœ ๋ณ€ํ™˜ (3๊ฐœ๋งŒ) + const recommendedDevices: RecentlyViewedDevice[] = (lifestyleData?.result?.devices ?? []) + .slice(0, 3) + .map((device) => ({ + deviceId: device.deviceId, + name: device.displayName, + modelCode: '', + brandName: '', + deviceType: '', + price: device.price, + priceCurrency: device.currency, + priceKrw: device.price, + imageUrl: device.imageUrl, + viewedAt: new Date().toISOString(), + })); + + // ๋‚ด ์กฐํ•ฉ์— ๋‹ด๊ธฐ ํ•ธ๋“ค๋Ÿฌ + const handleAddToCombo = async () => { + if (!comboId || !selectedDeviceId || isAdding) return; + + setIsAdding(true); + try { + await addDevice({ comboId, deviceId: selectedDeviceId }); + alert('์„ ํƒํ•œ ๊ธฐ๊ธฐ๊ฐ€ ๋‚ด ์กฐํ•ฉ์— ๋‹ด๊ฒผ์Šต๋‹ˆ๋‹ค!'); + navigate(ROUTES.home, { replace: true }); + } catch (error) { + const { message } = parseApiError(error); + alert(message || '์กฐํ•ฉ์— ๊ธฐ๊ธฐ๋ฅผ ๋‹ด๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); + } finally { + setIsAdding(false); + } + }; + + return ( +
+ {/* ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ */} +
+ {/* ์ฝ˜ํ…์ธ  ์˜์—ญ */} +
+ {/* ํƒ€์ดํ‹€ ์˜์—ญ */} +
+ {/* ๋ฉ”์ธ ํƒ€์ดํ‹€ */} + {hasTitleData ? ( +
+ {lifestyleTagLabel} + + ๋ฅผ ์„ ํƒํ•œ + + {userName}๋‹˜ + + ์„ ์œ„ํ•œ ์ถ”์ฒœ ๊ธฐ๊ธฐ +
+ ) : ( +

+ ํšŒ์›๋‹˜์„ ์œ„ํ•œ ์ถ”์ฒœ ๊ธฐ๊ธฐ +

+ )} + {/* ์„œ๋ธŒ ํƒ€์ดํ‹€ */} +

+ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๋ฐฉ๊ธˆ ์ƒ์„ฑํ•œ ๋‚ด ์กฐํ•ฉ์— ๋ฐ”๋กœ ๋‹ด์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +

+
+ + {/* ๊ธฐ๊ธฐ ์นด๋“œ ์˜์—ญ - ๊ฐ€๋กœ 3๊ฐœ */} +
+ {isDevicesLoading ? ( + + ) : recommendedDevices.length > 0 ? ( + recommendedDevices.map((device) => { + const isSelected = selectedDeviceId === device.deviceId; + return ( + selectDevice(device.deviceId)} + /> + ); + }) + ) : ( +

์ถ”์ฒœ ๊ธฐ๊ธฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

+ )} +
+ +
+ {/* ๋‚ด ์กฐํ•ฉ์— ๋‹ด๊ธฐ ๋ฒ„ํŠผ */} + + {/* ๋‹ค์Œ์— ํ•˜๊ธฐ ๋ฒ„ํŠผ */} + navigate(ROUTES.home, { replace: true })} + /> +
+
+
+
+ ); +}; + +export default OnboardingRecommendationPage; + diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx new file mode 100644 index 00000000..a836b358 --- /dev/null +++ b/src/routes/AppRoutes.tsx @@ -0,0 +1,12 @@ +import RootLayout from '@/layouts/RootLayout'; +import { PublicRoutes } from './PublicRoutes'; +import { PrivateRoutes } from './PrivateRoutes'; + +export const AppRoutes = { + path: '/', + element: , + children: [ + PublicRoutes, + PrivateRoutes, + ], +}; diff --git a/src/routes/PrivateRoutes.tsx b/src/routes/PrivateRoutes.tsx new file mode 100644 index 00000000..312b094f --- /dev/null +++ b/src/routes/PrivateRoutes.tsx @@ -0,0 +1,69 @@ +import { Navigate } from 'react-router-dom'; +import { AuthGuard } from './guards/AuthGuard'; +import { OnboardingOnlyGuard } from './guards/OnboardingOnlyGuard'; +import { OnboardingCompletedGuard } from './guards/OnboardingCompletedGuard'; + +// onboarding +import OnboardingLifestylePage from '@/pages/onboarding/OnboardingLifestylePage'; +import OnboardingCombinationPage from '@/pages/onboarding/OnboardingCombinationPage'; +import OnboardingCompletePage from '@/pages/onboarding/OnboardingCompletePage'; +import OnboardingRecommendationPage from '@/pages/onboarding/OnboardingRecommendationPage'; + + +// my +import MyPage from '@/pages/my/MyPage'; +import MyTrashPage from '@/pages/my/MyTrashPage'; +import MyCombinationDetailPage from '@/pages/my/MyCombinationDetailPage'; +import MySettingsProfilePage from '@/pages/my/settings/ProfileEditPage'; +import MySettingsPasswordPage from '@/pages/my/settings/PasswordEditPage'; + +import NotFoundPage from '@/pages/NotFoundPage'; + +/** + * - : ๋น„๋กœ๊ทธ์ธ ์ ‘๊ทผ ์ฐจ๋‹จ (์—†์œผ๋ฉด /auth/login ๋“ฑ์œผ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ) + * - : ๋กœ๊ทธ์ธ + ์˜จ๋ณด๋”ฉ "๋ฏธ์™„๋ฃŒ"๋งŒ ์ ‘๊ทผ ํ—ˆ์šฉ (์™„๋ฃŒ๋ฉด ๋ฉ”์ธ์œผ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ) + * - : ๋กœ๊ทธ์ธ + ์˜จ๋ณด๋”ฉ "์™„๋ฃŒ"๋งŒ ์ ‘๊ทผ ํ—ˆ์šฉ (๋ฏธ์™„๋ฃŒ๋ฉด /onboarding ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ) + */ +export const PrivateRoutes = { + element: , + children: [ + + // 1) ์˜จ๋ณด๋”ฉ ์ „์šฉ ์˜์—ญ: (๋กœ๊ทธ์ธ O) + (์˜จ๋ณด๋”ฉ ๋ฏธ์™„๋ฃŒ๋งŒ) + { + path: 'onboarding', + element: , + children: [ + { path: 'lifestyle', element: }, + { path: 'combination', element: }, + { path: '*', element: }, + ], + }, + + // 2) ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ ํŽ˜์ด์ง€: ๊ฐ€๋“œ ์—†์ด AuthGuard๋งŒ ์ ์šฉ (์ „ํ™˜ ํŽ˜์ด์ง€) + { path: 'onboarding/complete', element: }, + + // 3) ๋ฉ”์ธ ์•ฑ ์˜์—ญ: (๋กœ๊ทธ์ธ O) + (์˜จ๋ณด๋”ฉ ์™„๋ฃŒ๋งŒ) + { + element: , + children: [ + // ์ถ”์ฒœ ํŽ˜์ด์ง€ (์˜จ๋ณด๋”ฉ ์™„๋ฃŒ ํ›„ ์ ‘๊ทผ ๊ฐ€๋Šฅ) + { path: 'recommendation', element: }, + + // ๋งˆ์ดํŽ˜์ด์ง€ + { path: 'my', element: }, + { path: 'my/trash', element: }, + { path: 'my/combinations/:id', element: }, + + // ์„ค์ • + { + path: 'my/settings', + children: [ + { index: true, element: }, + { path: 'profile', element: }, + { path: 'password', element: }, + ], + }, + ], + }, + ], +}; diff --git a/src/routes/PublicRoutes.tsx b/src/routes/PublicRoutes.tsx new file mode 100644 index 00000000..7bd97300 --- /dev/null +++ b/src/routes/PublicRoutes.tsx @@ -0,0 +1,81 @@ +// HomePage +import HomePage from '@/pages/HomePage'; + +// lifestyle +import LifestylePage from '@/pages/lifestyle/LifestylePage'; + +// devices +import DeviceSearchPage from '@/pages/devices/DeviceSearchPage'; +import DeviceDetailPage from '@/pages/devices/DeviceDetailPage'; + +// combination +import CombinationCreatePage from '@/pages/combination/CombinationCreatePage'; + +// auth +import LoginPage from '@/pages/auth/LoginPage'; +import FindIdPage from '@/pages/auth/FindIdPage'; +import FindIdResultPage from '@/pages/auth/FindIdResultPage'; +import FindPasswordPage from '@/pages/auth/FindPasswordPage'; +import SignupPage from '@/pages/auth/SignupPage'; +import SignupAccountPage from '@/pages/auth/SignupAccountPage'; +import SignupProfilePage from '@/pages/auth/SignupProfilePage'; +import GoogleCallbackPage from '@/pages/auth/GoogleCallbackPage'; + +import NotFoundPage from '@/pages/NotFoundPage'; + +export const PublicRoutes = { + children: [ + // ํ™ˆ + { index: true, element: }, + + // ๋ผ์ดํ”„์Šคํƒ€์ผ (๋‹จ์ผ ํŽ˜์ด์ง€) + { path: 'lifestyle', element: }, + + // ๊ธฐ๊ธฐ ํƒ์ƒ‰/์ƒ์„ธ + { + path: 'devices', + children: [ + { index: true, element: }, + { path: ':deviceId', element: }, + ], + }, + + // ์กฐํ•ฉ ์ƒ์„ฑ + { path: 'combination/create', element: }, + + // OAuth ์ฝœ๋ฐฑ (๋‹จ๋…, ๊ฐ€๋“œ ์—†์Œ) + { path: 'auth/callback/google', element: }, + + // auth (๋กœ๊ทธ์ธ/ํšŒ์›๊ฐ€์ž…/์ฐพ๊ธฐ) + { + path: 'auth', + children: [ + { path: 'login', element: }, + + { + path: 'find', + children: [ + { path: 'id', element: }, + { path: 'id/result', element: }, + { path: 'password', element: }, + ], + }, + + { + path: 'signup', + children: [ + { index: true, element: }, + { path: 'account', element: }, + { path: 'profile', element: }, + ], + }, + + // /auth ํ•˜์œ„ ๋ฏธ๋งค์นญ fallback + { path: '*', element: }, + ], + }, + + // ์ „์ฒด ๋ฏธ๋งค์นญ fallback + { path: '*', element: }, + ], +}; diff --git a/src/routes/guards/AuthGuard.tsx b/src/routes/guards/AuthGuard.tsx new file mode 100644 index 00000000..8c66ed44 --- /dev/null +++ b/src/routes/guards/AuthGuard.tsx @@ -0,0 +1,26 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '@/hooks/useAuth'; +import { ROUTES } from '@/constants/routes'; +import LoadingSpinner from '@/components/LoadingSpinner'; + +/** + * ์ธ์ฆ ๊ฐ€๋“œ ์ปดํฌ๋„ŒํŠธ + * - ๋กœ๊ทธ์ธ ์ƒํƒœ๊ฐ€ ์•„๋‹ˆ๋ฉด ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + * - ๋กœ๊ทธ์ธ ์ƒํƒœ๋ฉด ์ž์‹ ๋ผ์šฐํŠธ ๋ Œ๋”๋ง + * - ๋กœ๋”ฉ ์ค‘์ผ ๋•Œ๋Š” ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ ํ‘œ์‹œ (ํ”Œ๋ฆฌ์ปค ๋ฐฉ์ง€) + */ +export const AuthGuard = () => { + const { isLoggedIn, isAuthLoading } = useAuth(); + + // ๋กœ๋”ฉ ์ค‘์ผ ๋•Œ๋Š” ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ ํ‘œ์‹œ + if (isAuthLoading) { + return ; + } + + if (!isLoggedIn) { + alert('๋กœ๊ทธ์ธ ํ›„ ์ด์šฉํ•ด์ฃผ์„ธ์š”.'); + return ; + } + + return ; +}; diff --git a/src/routes/guards/OnboardingCompletedGuard.tsx b/src/routes/guards/OnboardingCompletedGuard.tsx new file mode 100644 index 00000000..28cea6dc --- /dev/null +++ b/src/routes/guards/OnboardingCompletedGuard.tsx @@ -0,0 +1,24 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '@/hooks/useAuth'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import { ROUTES } from '@/constants/routes'; + +/* +์˜จ๋ณด๋”ฉ ์™„๋ฃŒ ๊ฐ€๋“œ ์ปดํฌ๋„ŒํŠธ + - ๋กœ๊ทธ์ธ + ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ๋งŒ ์ ‘๊ทผ ํ—ˆ์šฉ + - ์˜จ๋ณด๋”ฉ ๋ฏธ์™„๋ฃŒ ์‹œ ์˜จ๋ณด๋”ฉ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ +*/ +export const OnboardingCompletedGuard = () => { + const { isAuthLoading, hasCompletedOnboarding } = useAuth(); + + if (isAuthLoading) { + return ; + } + + if (!hasCompletedOnboarding) { + alert('์˜จ๋ณด๋”ฉ์„ ์™„๋ฃŒํ•œ ํ›„ ์ด์šฉํ•ด์ฃผ์„ธ์š”.'); + return ; + } + + return ; +}; diff --git a/src/routes/guards/OnboardingOnlyGuard.tsx b/src/routes/guards/OnboardingOnlyGuard.tsx new file mode 100644 index 00000000..c2810375 --- /dev/null +++ b/src/routes/guards/OnboardingOnlyGuard.tsx @@ -0,0 +1,24 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '@/hooks/useAuth'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import { ROUTES } from '@/constants/routes'; + +/* +์˜จ๋ณด๋”ฉ ์ „์šฉ ๊ฐ€๋“œ ์ปดํฌ๋„ŒํŠธ + - ๋กœ๊ทธ์ธ + ์˜จ๋ณด๋”ฉ ๋ฏธ์™„๋ฃŒ๋งŒ ์ ‘๊ทผ ํ—ˆ์šฉ + - ์˜จ๋ณด๋”ฉ ์™„๋ฃŒ ์‹œ ํ™ˆ์œผ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ +*/ +export const OnboardingOnlyGuard = () => { + const { isAuthLoading, hasCompletedOnboarding } = useAuth(); + + if (isAuthLoading) { + return ; + } + + if (hasCompletedOnboarding) { + alert('์˜จ๋ณด๋”ฉ์ด ์ด๋ฏธ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + return ; + } + + return ; +}; diff --git a/src/routes/router.ts b/src/routes/router.ts new file mode 100644 index 00000000..d3b8d356 --- /dev/null +++ b/src/routes/router.ts @@ -0,0 +1,4 @@ +import { createBrowserRouter } from 'react-router-dom'; +import { AppRoutes } from './AppRoutes'; + +export const router = createBrowserRouter([AppRoutes]); diff --git a/src/schemas/authSchema.ts b/src/schemas/authSchema.ts new file mode 100644 index 00000000..96c59747 --- /dev/null +++ b/src/schemas/authSchema.ts @@ -0,0 +1,103 @@ +import { z } from 'zod'; + +// ์ด๋ฉ”์ผ ์Šคํ‚ค๋งˆ (์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅ) +export const emailSchema = z.string().email('์œ ํšจํ•œ ์ด๋ฉ”์ผ์„ ์ž…๋ ฅํ•˜์„ธ์š”'); + +// ๋น„๋ฐ€๋ฒˆํ˜ธ ์Šคํ‚ค๋งˆ (์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅ) +export const passwordSchema = z + .string() + .min(8, '8์ž ์ด์ƒ ์ž…๋ ฅํ•˜์„ธ์š”') + .max(20, '20์ž ์ดํ•˜๋กœ ์ž…๋ ฅํ•˜์„ธ์š”') + .regex(/^(?=.*[a-zA-Z])(?=.*\d)/, '์˜๋ฌธ๊ณผ ์ˆซ์ž๋ฅผ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค'); + +// ์ด๋ฆ„ ์Šคํ‚ค๋งˆ (์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅ) +export const nameSchema = z + .string() + .min(1, '์ด๋ฆ„์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”') + .refine( + (value) => /^[a-zA-Z๊ฐ€-ํžฃ\s]+$/.test(value), + 'ํ•œ๊ธ€ ๋˜๋Š” ์˜๋ฌธ๋งŒ ์ž…๋ ฅ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค' + ); + +// ํœด๋Œ€ํฐ ๋ฒˆํ˜ธ ์Šคํ‚ค๋งˆ (์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅ) +export const phoneSchema = z + .string() + .min(1, 'ํœด๋Œ€ํฐ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”') + .regex(/^[0-9]{10,11}$/, 'ํœด๋Œ€ํฐ ๋ฒˆํ˜ธ ํ˜•์‹์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”'); + +// ๋กœ๊ทธ์ธ ์Šคํ‚ค๋งˆ +export const loginSchema = z.object({ + email: emailSchema, + password: passwordSchema, +}); + +export type LoginFormData = z.infer; + +// ์•„์ด๋”” ์ฐพ๊ธฐ ์Šคํ‚ค๋งˆ +export const findIdSchema = z.object({ + name: nameSchema, + phone: phoneSchema, +}); + +export type FindIdFormData = z.infer; + +// ๋น„๋ฐ€๋ฒˆํ˜ธ ์ฐพ๊ธฐ ์Šคํ‚ค๋งˆ +export const findPasswordSchema = z.object({ + email: emailSchema, +}); + +export type FindPasswordFormData = z.infer; + +// ๋น„๋ฐ€๋ฒˆํ˜ธ ์žฌ์„ค์ • ์Šคํ‚ค๋งˆ +export const resetPasswordSchema = z + .object({ + newPassword: passwordSchema, + newPasswordConfirm: z.string().min(1, '๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ์„ ์ž…๋ ฅํ•˜์„ธ์š”'), + }) + .refine((data) => data.newPassword === data.newPasswordConfirm, { + message: '๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค', + path: ['newPasswordConfirm'], // ์—๋Ÿฌ๋ฅผ newPasswordConfirm ํ•„๋“œ์—๋งŒ ํ‘œ์‹œ + }); + +export type ResetPasswordFormData = z.infer; + +// ํšŒ์›๊ฐ€์ž… - ๊ณ„์ • ์ •๋ณด ์Šคํ‚ค๋งˆ +export const signupAccountSchema = z + .object({ + email: emailSchema, + password: passwordSchema, + passwordConfirm: z.string().min(1, '๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ์„ ์ž…๋ ฅํ•˜์„ธ์š”'), + }) + .refine((data) => data.password === data.passwordConfirm, { + message: '๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค', + path: ['passwordConfirm'], // ์—๋Ÿฌ๋ฅผ passwordConfirm ํ•„๋“œ์—๋งŒ ํ‘œ์‹œ + }); + +export type SignupAccountFormData = z.infer; + +// ํšŒ์›๊ฐ€์ž… - ํ”„๋กœํ•„ ์ •๋ณด ์Šคํ‚ค๋งˆ +export const signupProfileSchema = z.object({ + name: nameSchema, + phone: phoneSchema, +}); + +export type SignupProfileFormData = z.infer; + +// ์กฐํ•ฉ๋ช… ์Šคํ‚ค๋งˆ +export const combinationSchema = z.object({ + combinationName: z.string().min(1, '์กฐํ•ฉ๋ช…์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'), +}); + +export type CombinationFormData = z.infer; + +// ์˜จ๋ณด๋”ฉ ์กฐํ•ฉ๋ช… ์Šคํ‚ค๋งˆ +export const onboardingCombinationSchema = z.object({ + combinationName: z + .string() + .min(1, '์กฐํ•ฉ๋ช…์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”') + .max(20, '์กฐํ•ฉ๋ช…์€ ์ตœ๋Œ€ 20์ž๊นŒ์ง€ ์ž…๋ ฅ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.') + .regex(/^[๊ฐ€-ํžฃa-zA-Z0-9 ]+$/, 'ํŠน์ˆ˜๋ฌธ์ž๋‚˜ ์ด๋ชจ์ง€๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.') + .refine((val) => val.trim().length > 0, '์กฐํ•ฉ๋ช…์„ ํ•œ ๊ธ€์ž ์ด์ƒ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'), +}); + +export type OnboardingCombinationFormData = z.infer; diff --git a/src/stores/signupStore.ts b/src/stores/signupStore.ts new file mode 100644 index 00000000..9932183f --- /dev/null +++ b/src/stores/signupStore.ts @@ -0,0 +1,56 @@ +import { create } from 'zustand'; + +type SignupAccountState = { + email: string; + password: string; +}; + +type SignupProfileState = { + username: string; + phoneNumber: string; +}; + +type SignupStoreState = { + account: SignupAccountState; + profile: SignupProfileState; + isEmailVerified: boolean; + setAccount: (account: SignupAccountState) => void; + setProfile: (profile: SignupProfileState) => void; + setIsEmailVerified: (value: boolean) => void; + resetSignup: () => void; +}; + +export const useSignupStore = create((set) => ({ + account: { + email: '', + password: '', + }, + profile: { + username: '', + phoneNumber: '', + }, + isEmailVerified: false, + + setAccount: (account) => + set(() => ({ + account, + })), + + setProfile: (profile) => + set(() => ({ + profile, + })), + + setIsEmailVerified: (value) => + set(() => ({ + isEmailVerified: value, + })), + + resetSignup: () => + set(() => ({ + account: { email: '', password: '' }, + profile: { username: '', phoneNumber: '' }, + isEmailVerified: false, + })), +})); + diff --git a/src/types/auth/login.ts b/src/types/auth/login.ts new file mode 100644 index 00000000..b1b294f1 --- /dev/null +++ b/src/types/auth/login.ts @@ -0,0 +1,18 @@ +import type { CommonResponse } from '@/types/common'; + +// ๋กœ๊ทธ์ธ ์š”์ฒญ ํƒ€์ž… +export type LoginRequest = { + email: string; + password: string; + keepLogin?: boolean; +}; + +// ๋กœ๊ทธ์ธ ์‘๋‹ต result ํƒ€์ž… +export type LoginResult = { + userId: number; + accessToken: string; + refreshToken: null; // httpOnly ์ฟ ํ‚ค๋กœ๋งŒ ์ „์†ก๋˜๋ฏ€๋กœ ์‘๋‹ต์—์„œ๋Š” ํ•ญ์ƒ null +}; + +// ๋กœ๊ทธ์ธ ์‘๋‹ต ํƒ€์ž… +export type LoginResponse = CommonResponse; diff --git a/src/types/auth/logout.ts b/src/types/auth/logout.ts new file mode 100644 index 00000000..76430722 --- /dev/null +++ b/src/types/auth/logout.ts @@ -0,0 +1,4 @@ +import type { CommonResponse } from '@/types/common'; + +// ๋กœ๊ทธ์•„์›ƒ ์‘๋‹ต ํƒ€์ž… (result ์—†์Œ) +export type LogoutResponse = CommonResponse; diff --git a/src/types/auth/refresh.ts b/src/types/auth/refresh.ts new file mode 100644 index 00000000..127516ac --- /dev/null +++ b/src/types/auth/refresh.ts @@ -0,0 +1,10 @@ +import type { CommonResponse } from '@/types/common'; + +// ํ† ํฐ ์žฌ๋ฐœ๊ธ‰ ์‘๋‹ต result ํƒ€์ž… +export type RefreshTokenResult = { + userId: number; + accessToken: string; +}; + +// ํ† ํฐ ์žฌ๋ฐœ๊ธ‰ ์‘๋‹ต ํƒ€์ž… +export type RefreshTokenResponse = CommonResponse; diff --git a/src/types/auth/signup.ts b/src/types/auth/signup.ts new file mode 100644 index 00000000..c6bfb8fa --- /dev/null +++ b/src/types/auth/signup.ts @@ -0,0 +1,32 @@ +import type { CommonResponse } from '@/types/common'; + +// ์ด๋ฉ”์ผ ์ค‘๋ณตํ™•์ธ ์š”์ฒญ ํƒ€์ž… +export type EmailDuplicateRequest = { + email: string; +}; + +// ์ด๋ฉ”์ผ ์ค‘๋ณตํ™•์ธ ์‘๋‹ต result ํƒ€์ž… +export type EmailDuplicateResult = { + success: boolean; +}; + +// ์ด๋ฉ”์ผ ์ค‘๋ณตํ™•์ธ ์‘๋‹ต ํƒ€์ž… +export type EmailDuplicateResponse = CommonResponse; + + +// ํšŒ์›๊ฐ€์ž… ์š”์ฒญ ํƒ€์ž… +export type SignupRequest = { + email: string; + password: string; + username: string; + phoneNumber: string; +}; + +// ํšŒ์›๊ฐ€์ž… ์‘๋‹ต result ํƒ€์ž… +export type SignupResult = { + userId: number; +}; + +// ํšŒ์›๊ฐ€์ž… ์‘๋‹ต ํƒ€์ž… +export type SignupResponse = CommonResponse; + diff --git a/src/types/combo/combo.ts b/src/types/combo/combo.ts new file mode 100644 index 00000000..b79c737a --- /dev/null +++ b/src/types/combo/combo.ts @@ -0,0 +1,83 @@ +import type { CommonResponse } from '@/types/common'; + +// ์กฐํ•ฉ ๋‚ด ๊ธฐ๊ธฐ ํƒ€์ž… (API ์‘๋‹ต ๊ตฌ์กฐ) +export type ComboDevice = { + deviceId: number; + name: string; + modelCode: string; + brandName: string; + deviceType: string; + price: number; + priceCurrency: string; + imageUrl: string; + addedAt: string; +}; + +// ์กฐํ•ฉ ๋ชฉ๋ก ์•„์ดํ…œ +export type ComboListItem = { + comboId: number; + comboName: string; + isPinned: boolean; + pinnedAt: string | null; + totalPrice: number; + currentTotalScore: number; + connectivityGrade?: string; + convenienceGrade?: string; + lifestyleGrade?: string; + deviceCount: number; + createdAt: string; + updatedAt: string; + devices: ComboDevice[]; +}; + +// ์กฐํ•ฉ ์ƒ์„ธ ์ •๋ณด +export type ComboDetail = { + comboId: number; + comboName: string; + isPinned: boolean; + pinnedAt: string | null; + totalPrice: number; + currentTotalScore: number; + connectivityGrade?: string; + convenienceGrade?: string; + lifestyleGrade?: string; + evaluatedAt: string | null; + createdAt: string; + updatedAt: string; + devices: ComboDevice[]; +}; + +// ์‘๋‹ต ํƒ€์ž… +export type GetCombosResult = ComboListItem[]; +export type GetCombosResponse = CommonResponse; + +export type GetComboResult = ComboDetail; +export type GetComboResponse = CommonResponse; + +// ์กฐํ•ฉ ์ˆ˜์ • ์š”์ฒญ/์‘๋‹ต ํƒ€์ž… +export type PutComboRequest = { + comboName: string; +}; + +export type PutComboResult = null; +export type PutComboResponse = CommonResponse; + +// ์กฐํ•ฉ์— ๊ธฐ๊ธฐ ์ถ”๊ฐ€ ์š”์ฒญ/์‘๋‹ต ํƒ€์ž… +export type PostComboDeviceRequest = { + deviceId: number; +}; + +export type PostComboDeviceResult = ComboDetail; +export type PostComboDeviceResponse = CommonResponse; + +// ์กฐํ•ฉ ์‚ญ์ œ ์‘๋‹ต ํƒ€์ž… +export type DeleteComboResult = null; +export type DeleteComboResponse = CommonResponse; + +// ์กฐํ•ฉ Pin ์‘๋‹ต ํƒ€์ž… +export type PostComboPinResult = null; +export type PostComboPinResponse = CommonResponse; + +// ์กฐํ•ฉ์—์„œ ๊ธฐ๊ธฐ ์‚ญ์ œ ์‘๋‹ต ํƒ€์ž… +export type DeleteComboDeviceResult = null; +export type DeleteComboDeviceResponse = CommonResponse; diff --git a/src/types/combo/createCombo.ts b/src/types/combo/createCombo.ts new file mode 100644 index 00000000..3784951a --- /dev/null +++ b/src/types/combo/createCombo.ts @@ -0,0 +1,15 @@ +import type { CommonResponse } from '../common'; + +// ์กฐํ•ฉ ์ƒ์„ฑ ์š”์ฒญ ํƒ€์ž… +export type PostCreateCombinationRequest = { + comboName: string; // ์ตœ๋Œ€ 80์ž +}; + +// ์กฐํ•ฉ ์ƒ์„ฑ ์‘๋‹ต result ํƒ€์ž… +export type PostCreateCombinationResult = { + comboId: number; + comboName: string; +}; + +// ์กฐํ•ฉ ์ƒ์„ฑ ์‘๋‹ต ํƒ€์ž… +export type PostCreateCombinationResponse = CommonResponse; diff --git a/src/types/combo/evaluation.ts b/src/types/combo/evaluation.ts new file mode 100644 index 00000000..1bf84ce4 --- /dev/null +++ b/src/types/combo/evaluation.ts @@ -0,0 +1,17 @@ +import type { CommonResponse } from '../common'; + +// ์กฐํ•ฉ ํ‰๊ฐ€ ์ ์ˆ˜ ๊ฒฐ๊ณผ ํƒ€์ž… +export interface ComboEvaluationResult { + comboId: number; + totalScore: number; + connectivity: number; + connectivityGrade: string; + convenience: number; + convenienceGrade: string; + lifestyle: number; + lifestyleGrade: string; + evaluatedAt: string; // ISO date string +} + +// ์กฐํ•ฉ ํ‰๊ฐ€ ์ ์ˆ˜ ์กฐํšŒ ์‘๋‹ต ํƒ€์ž… +export type GetComboEvaluationResponse = CommonResponse; diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 00000000..152181a1 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,7 @@ +export type CommonResponse = { + success: boolean; + code: string; + message: string; + result?: T; + error?: Record | null; +}; \ No newline at end of file diff --git a/src/types/devices.ts b/src/types/devices.ts new file mode 100644 index 00000000..d6564bee --- /dev/null +++ b/src/types/devices.ts @@ -0,0 +1,65 @@ +import { type CombinationName, type CombinationStatus } from '@/constants/combination'; +import type { CommonResponse } from '@/types/common'; + +export type AuthStatus = 'logout' | 'login'; +export type ModalView = 'device' | 'combination' | 'combinationDetail'; + +export interface Brand { + brandId: number; + brandName: string; +} + +export interface GetBrandsResponse { + code: string; + message: string; + result: Brand[]; +} + +export type CombinationTagType = { + name: CombinationName; + status: CombinationStatus; +}; + +export type UserCombination = { + id: number; + label: string; + name: string; + isMain: boolean; + createdAt?: string; + tags: CombinationTagType[]; +}; + +// ๊ธฐ๊ธฐ ๊ฒ€์ƒ‰ API ํŒŒ๋ผ๋ฏธํ„ฐ +export interface SearchDevicesParams { + keyword?: string; + cursor?: string; + size?: number; + sortType?: 'LATEST' | 'NAME_ASC' | 'PRICE_ASC' | 'PRICE_DESC'; + deviceTypes?: string[]; + minPrice?: number; + maxPrice?: number; + brandIds?: number[]; +} + +// ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ๊ธฐ๊ธฐ +export interface SearchDevice { + deviceId: number; + deviceType: string; + brandName: string; + name: string; + price: number; + priceCurrency: string; + imageUrl: string; + releaseDate: string; + specifications: Record; +} + +// ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ +export interface DeviceSearchResult { + devices: SearchDevice[]; + nextCursor: string | null; + hasNext: boolean; +} + +// API ์‘๋‹ต +export type GetDevicesSearchResponse = CommonResponse; diff --git a/src/types/findCredential/findId.ts b/src/types/findCredential/findId.ts new file mode 100644 index 00000000..2980dfc8 --- /dev/null +++ b/src/types/findCredential/findId.ts @@ -0,0 +1,15 @@ +import type { CommonResponse } from '@/types/common'; + +// ์•„์ด๋”” ์ฐพ๊ธฐ ์š”์ฒญ ํƒ€์ž… +export type FindIdRequest = { + username: string; + phoneNumber: string; +}; + +// ์•„์ด๋”” ์ฐพ๊ธฐ ์‘๋‹ต result ํƒ€์ž… +export type FindIdResult = { + emailInfo: string; +}; + +// ์•„์ด๋”” ์ฐพ๊ธฐ ์‘๋‹ต ํƒ€์ž… +export type FindIdResponse = CommonResponse; diff --git a/src/types/findCredential/findPassword.ts b/src/types/findCredential/findPassword.ts new file mode 100644 index 00000000..cc20b317 --- /dev/null +++ b/src/types/findCredential/findPassword.ts @@ -0,0 +1,33 @@ +import type { CommonResponse } from '@/types/common'; + +// step1 - ๋ฉ”์ผ ์ „์†ก ์š”์ฒญ ํƒ€์ž… +export type SendMailRequest = { + email: string; +}; + +// step1 - ๋ฉ”์ผ ์ „์†ก ์‘๋‹ต ํƒ€์ž… +export type SendMailResponse = CommonResponse; + + +// step2 - ์ธ์ฆ์ฝ”๋“œ ํ™•์ธ ์š”์ฒญ ํƒ€์ž… +export type VerifyCodeRequest = { + code: string; +}; + +// step2 - ์ธ์ฆ์ฝ”๋“œ ํ™•์ธ ์‘๋‹ต result ํƒ€์ž… +export type VerifyCodeResult = { + verifyToken: string; +}; + +// step2 - ์ธ์ฆ์ฝ”๋“œ ํ™•์ธ ์‘๋‹ต ํƒ€์ž… +export type VerifyCodeResponse = CommonResponse; + + +// step3 - ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ฆฌ์…‹ ์š”์ฒญ ํƒ€์ž… +export type ResetPasswordRequest = { + verifiedToken: string; + newPassword: string; +}; + +// step3 - ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ฆฌ์…‹ ์‘๋‹ต ํƒ€์ž… +export type ResetPasswordResponse = CommonResponse; diff --git a/src/types/lifestyle/lifestyle.ts b/src/types/lifestyle/lifestyle.ts new file mode 100644 index 00000000..6dfa7190 --- /dev/null +++ b/src/types/lifestyle/lifestyle.ts @@ -0,0 +1,25 @@ +import { type CommonResponse } from '@/types/common'; + +export type LifestyleTagKey = 'Office' | 'Developer' | 'Game' | 'Study' | 'Video-editing' | 'Tour'; +export type LifestyleTagType = 'LIFESTYLE'; + +export type LifestyleDevice = { + slot: number; + deviceId: number; + imageUrl: string; + displayName: string; + releaseDate: string; + price: number; + currency: 'KRW'; +}; + +export type LifestyleDeviceResult = { + tagKey: LifestyleTagKey; + tagLabel: string; + tagType: LifestyleTagType; + devices: LifestyleDevice[]; +}; + +export type LifestyleDeviceResponse = CommonResponse; +export type LifestyleDeviceErrorResponse = CommonResponse; + diff --git a/src/types/mypage/editPassword.ts b/src/types/mypage/editPassword.ts new file mode 100644 index 00000000..7987b991 --- /dev/null +++ b/src/types/mypage/editPassword.ts @@ -0,0 +1,11 @@ +import type { CommonResponse } from '../common'; + +// ๋น„๋ฐ€๋ฒˆํ˜ธ ์ˆ˜์ • request +export type EditPasswordRequest = { + oldPassword: string; + newPassword: string; + newPasswordConfirm: string; +}; + +// ๋น„๋ฐ€๋ฒˆํ˜ธ ์ˆ˜์ • response +export type EditPasswordResponse = CommonResponse; \ No newline at end of file diff --git a/src/types/mypage/editProfile.ts b/src/types/mypage/editProfile.ts new file mode 100644 index 00000000..a34aacf5 --- /dev/null +++ b/src/types/mypage/editProfile.ts @@ -0,0 +1,17 @@ +import type { CommonResponse } from '@/types/common'; + +// ํ”„๋กœํ•„ ์ˆ˜์ • Request +export type EditProfileRequest = { + username: string | null; + email: string | null; + lifestyleList: string[] | null; +}; + +export type EditProfileResult = { + username: string; + email: string; + lifestyleList: string[]; +}; + +// ํ”„๋กœํ•„ ์ˆ˜์ • Response +export type EditProfileResponse = CommonResponse; diff --git a/src/types/mypage/user.ts b/src/types/mypage/user.ts new file mode 100644 index 00000000..0b339ff3 --- /dev/null +++ b/src/types/mypage/user.ts @@ -0,0 +1,14 @@ +import type { CommonResponse } from '@/types/common'; + +// ์œ ์ € ์ •๋ณด ์‘๋‹ต result ํƒ€์ž… +export type UserProfileResult = { + username: string; + createdAt: string; + email: string; + lifestyleList: string[]; + authProvider: string; + isOnboardingCompleted: boolean; +}; + +// ์œ ์ € ์ •๋ณด ์‘๋‹ต ํƒ€์ž… +export type UserProfileResponse = CommonResponse; diff --git a/src/types/onboarding/complete.ts b/src/types/onboarding/complete.ts new file mode 100644 index 00000000..495cfc9e --- /dev/null +++ b/src/types/onboarding/complete.ts @@ -0,0 +1,3 @@ +import type { CommonResponse } from '@/types/common'; + +export type PostOnboardingCompleteResponse = CommonResponse; diff --git a/src/types/product.ts b/src/types/product.ts new file mode 100644 index 00000000..8d12cc4b --- /dev/null +++ b/src/types/product.ts @@ -0,0 +1,8 @@ +export interface Product { + id: number; + name: string; + category: string; + price: number; + image: string | null; + colors: string[]; +} diff --git a/src/types/recentlyViewed/recentlyViewed.ts b/src/types/recentlyViewed/recentlyViewed.ts new file mode 100644 index 00000000..27b3c7cd --- /dev/null +++ b/src/types/recentlyViewed/recentlyViewed.ts @@ -0,0 +1,18 @@ +import type { CommonResponse } from '@/types/common'; + +// ์ตœ๊ทผ ๋ณธ ๊ธฐ๊ธฐ ๋ฐ์ดํ„ฐ ํƒ€์ž… (API ์‘๋‹ต ํ˜•์‹) +export interface RecentlyViewedDevice { + deviceId: number; + name: string; + modelCode: string; + brandName: string; + deviceType: string; + price: number; + priceCurrency: string; + priceKrw: number; + imageUrl: string; + viewedAt: string; // ISO date string +} + +// ์ตœ๊ทผ ๋ณธ ๊ธฐ๊ธฐ ๋ชฉ๋ก ์กฐํšŒ ์‘๋‹ต ํƒ€์ž… (API ์‘๋‹ต) +export type RecentlyViewedDevicesResponse = CommonResponse; diff --git a/src/types/tag/tag.ts b/src/types/tag/tag.ts new file mode 100644 index 00000000..308a8ec1 --- /dev/null +++ b/src/types/tag/tag.ts @@ -0,0 +1,27 @@ +import type { CommonResponse } from "../common"; + +export type TagType = 'INTEREST' | 'LIFESTYLE' | 'BRAND'; + +export type Tag = { + tagId: number; + tagKey: string; + tagLabel: string; + tagType: TagType; +}; + +// ํƒœ๊ทธ ์กฐํšŒ ์‘๋‹ต result ํƒ€์ž… +export type GetTagsResult = Tag[]; + +// ํƒœ๊ทธ ์กฐํšŒ ์‘๋‹ต ํƒ€์ž… +export type GetTagsResponse = CommonResponse; + +// ์œ ์ € ํƒœ๊ทธ ๋“ฑ๋ก ์š”์ฒญ ํƒ€์ž… +export type PostUserTagsRequest = { + tagIds: number[]; +}; + +// ์œ ์ € ํƒœ๊ทธ ๋“ฑ๋ก ์‘๋‹ต result ํƒ€์ž… +export type PostUserTagsResult = null; + +// ์œ ์ € ํƒœ๊ทธ ๋“ฑ๋ก ์‘๋‹ต ํƒ€์ž… +export type PostUserTagsResponse = CommonResponse; \ No newline at end of file diff --git a/src/utils/authStorage.ts b/src/utils/authStorage.ts new file mode 100644 index 00000000..c4cff1a1 --- /dev/null +++ b/src/utils/authStorage.ts @@ -0,0 +1,64 @@ +import { ACCESS_TOKEN, AUTH_STORAGE } from '@/constants/tokenKey'; + +// ์ €์žฅ์†Œ ํƒ€์ž…: local(์˜์†) | session(์„ธ์…˜) +export type AuthStorageType = 'local' | 'session'; + + + +// Storage ๊ฐ์ฒด๋ฅผ ์กฐ์ž‘ํ•˜๋Š” ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜ +// keepLogin ์ฒดํฌ ์—ฌ๋ถ€์— ๋”ฐ๋ผ localStorage(์˜์†) ๋˜๋Š” sessionStorage(์„ธ์…˜) ์‚ฌ์šฉ + +// ํ˜„์žฌ ์‚ฌ์šฉ ์ค‘์ธ ์ €์žฅ์†Œ ๋ฐ˜ํ™˜ ํ•จ์ˆ˜ (auth_storage ํ”Œ๋ž˜๊ทธ ๊ธฐ์ค€, ์—†์œผ๋ฉด local๋กœ ๊ฐ„์ฃผ) +export const getAuthStorageType = (): AuthStorageType => { + const stored = localStorage.getItem(AUTH_STORAGE); + if (stored === 'session') return 'session'; + return 'local'; // 'local' ๋˜๋Š” legacy(ํ”Œ๋ž˜๊ทธ ์—†์Œ) +}; + +// ํ† ํฐ์ด ์ €์žฅ๋œ Storage ๊ฐ์ฒด ๋ฐ˜ํ™˜ ํ•จ์ˆ˜ +const getTokenStorage = (): Storage => { + return getAuthStorageType() === 'session' ? sessionStorage : localStorage; +}; + +/* +์•ก์„ธ์Šค ํ† ํฐ ์ €์žฅ ํ•จ์ˆ˜ + * Cross-Contamination ๋ฐฉ์ง€: ํ† ํฐ์€ ํ•œ ๊ตฐ๋ฐ์—๋งŒ ์กด์žฌ. ๋ฐ˜๋Œ€ํŽธ ์ €์žฅ์†Œ ํ† ํฐ์€ ๋ฐ˜๋“œ์‹œ ์‚ญ์ œ. + * @param accessToken - ์•ก์„ธ์Šค ํ† ํฐ + * @param keepLogin + * - true: localStorage(์˜์†), false: sessionStorage(์„ธ์…˜), undefined: ๊ธฐ์กด ์ €์žฅ์†Œ ์œ ์ง€(ํ† ํฐ ๊ฐฑ์‹  ์‹œ) + */ +export const setAccessToken = (accessToken: string, keepLogin?: boolean): void => { + if (keepLogin === true) { + localStorage.setItem(ACCESS_TOKEN, accessToken); + localStorage.setItem(AUTH_STORAGE, 'local'); + sessionStorage.removeItem(ACCESS_TOKEN); + } else if (keepLogin === false) { + sessionStorage.setItem(ACCESS_TOKEN, accessToken); + localStorage.setItem(AUTH_STORAGE, 'session'); + localStorage.removeItem(ACCESS_TOKEN); + } else { + // ํ† ํฐ ๊ฐฑ์‹  ์‹œ: ๊ธฐ์กด ์ €์žฅ์†Œ์— ๋ฎ์–ด์“ฐ๊ธฐ + ๋ฐ˜๋Œ€ํŽธ ์‚ญ์ œ + const storage = getTokenStorage(); + storage.setItem(ACCESS_TOKEN, accessToken); + const oppositeStorage = storage === localStorage ? sessionStorage : localStorage; + oppositeStorage.removeItem(ACCESS_TOKEN); + } +}; + +// ์•ก์„ธ์Šค ํ† ํฐ ๊ฐ€์ ธ์˜ค๊ธฐ ํ•จ์ˆ˜ +export const getAccessToken = (): string | null => { + return getTokenStorage().getItem(ACCESS_TOKEN); +}; + +// ์•ก์„ธ์Šค ํ† ํฐ ์‚ญ์ œ ํ•จ์ˆ˜ (refreshToken์€ ์„œ๋ฒ„์—์„œ ์ฟ ํ‚ค ์‚ญ์ œ) +export const clearAccessToken = (): void => { + localStorage.removeItem(ACCESS_TOKEN); + localStorage.removeItem(AUTH_STORAGE); + sessionStorage.removeItem(ACCESS_TOKEN); +}; + +// ์•ก์„ธ์Šค ํ† ํฐ ์กด์žฌ ์—ฌ๋ถ€ ์ฒดํฌ ํ•จ์ˆ˜ (refreshToken์€ httpOnly ์ฟ ํ‚ค) +export const hasAccessToken = (): boolean => { + const accessToken = getAccessToken(); + return !!accessToken; +}; diff --git a/src/utils/cn.ts b/src/utils/cn.ts new file mode 100644 index 00000000..99a01736 --- /dev/null +++ b/src/utils/cn.ts @@ -0,0 +1,9 @@ +// Tailwind CSS ํด๋ž˜์Šค ๋ณ‘ํ•ฉ ์œ ํ‹ธ๋ฆฌํ‹ฐ +// - ๊ธฐ๋Šฅ : Tailwind CSS ํด๋ž˜์Šค๋ฅผ ๋ณ‘ํ•ฉํ•˜์—ฌ ํ•˜๋‚˜์˜ ๋ฌธ์ž์—ด๋กœ ๋งŒ๋“ค์–ด์คŒ + +import { clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: any[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/utils/combinationFloating.ts b/src/utils/combinationFloating.ts new file mode 100644 index 00000000..cb143e8d --- /dev/null +++ b/src/utils/combinationFloating.ts @@ -0,0 +1,82 @@ +type FloatingStyleSource = HTMLElement | null; + +export const createFloating = ({ + text, + startLeft, + startTop, + width, + height, + padding = 20, +}: { + text: string; + startLeft: number; + startTop: number; + width: number; + height: number; + padding?: number; +}) => { + const floating = document.createElement('div'); + floating.textContent = text; + floating.style.position = 'fixed'; + floating.style.left = `${startLeft}px`; + floating.style.top = `${startTop}px`; + floating.style.width = `${width}px`; + floating.style.height = `${height}px`; + floating.style.padding = `${padding}px`; + floating.style.display = 'flex'; + floating.style.flexDirection = 'column'; + floating.style.justifyContent = 'center'; + floating.style.alignItems = 'center'; + floating.style.gap = '10px'; + floating.style.zIndex = '9999'; + floating.style.pointerEvents = 'none'; + floating.style.willChange = 'transform, opacity'; + floating.style.opacity = '1'; + document.body.appendChild(floating); + return floating; +}; + +export const copyComputedStyle = (target: HTMLElement, source: FloatingStyleSource) => { + if (!source) return; + + const cs = window.getComputedStyle(source); + + target.style.borderRadius = cs.borderRadius; + target.style.boxShadow = cs.boxShadow; + target.style.backgroundColor = cs.backgroundColor; + target.style.border = cs.border; + target.style.fontFamily = cs.fontFamily; + target.style.fontSize = cs.fontSize; + target.style.fontWeight = cs.fontWeight; + target.style.lineHeight = cs.lineHeight; + target.style.letterSpacing = cs.letterSpacing; + target.style.color = cs.color; + target.style.textAlign = cs.textAlign; +}; + +export const animateDrop = ({ + el, + dx, + dy, + duration, + easing, +}: { + el: HTMLElement; + dx: number; + dy: number; + duration: number; + easing: string; +}) => { + const anim = el.animate( + [{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(${dx}px, ${dy}px, 0)` }], + { duration, easing, fill: 'forwards' } + ); + return anim; +}; + + +export const fadeOutAndRemove = (el: HTMLElement, ms = 240) => { + el.style.transition = `opacity ${ms}ms ease-out`; + el.style.opacity = '0'; + window.setTimeout(() => el.remove(), ms + 20); +}; diff --git a/src/utils/devices/getBaseModelName.ts b/src/utils/devices/getBaseModelName.ts new file mode 100644 index 00000000..117ab2ad --- /dev/null +++ b/src/utils/devices/getBaseModelName.ts @@ -0,0 +1,76 @@ +/** + * ๊ธฐ๊ธฐ๋ช…์—์„œ ์šฉ๋Ÿ‰๊ณผ ์ƒ‰์ƒ ์ •๋ณด๋ฅผ ์ œ๊ฑฐํ•˜์—ฌ ๋ฒ ์ด์Šค ๋ชจ๋ธ๋ช…์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + * ๊ฐ™์€ ๋ชจ๋ธ์˜ ๋‹ค๋ฅธ ์šฉ๋Ÿ‰/์ƒ‰์ƒ ๊ธฐ๊ธฐ๋ฅผ ๋™์ผํ•œ ๋ชจ๋ธ๋กœ ์ธ์‹ํ•˜๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. + * + * @param name - ์ „์ฒด ๊ธฐ๊ธฐ๋ช… (์˜ˆ: "iPhone 15 Pro ๋ธ”๋ž™ 512GB") + * @returns ๋ฒ ์ด์Šค ๋ชจ๋ธ๋ช… (์˜ˆ: "iPhone 15 Pro") + * + * @example + * getBaseModelName("iPhone 15 Pro ๋ธ”๋ž™ 512GB") // "iPhone 15 Pro" + * getBaseModelName("Samsung Galaxy S24 Ultra ํ™”์ดํŠธ 256GB") // "Samsung Galaxy S24 Ultra" + * getBaseModelName("MacBook Pro 14 1TB") // "MacBook Pro 14" + */ +export const getBaseModelName = (name: string): string => { + if (!name) return ''; + + let baseName = name; + + // 1. ์šฉ๋Ÿ‰ ์ •๋ณด ์ œ๊ฑฐ (512GB, 1TB, 256MB ๋“ฑ) + baseName = baseName.replace(/\s*\d+\s*(GB|TB|MB)\s*/gi, ' '); + + // 2. ์ƒ‰์ƒ ์ •๋ณด ์ œ๊ฑฐ + const colorPatterns = [ + // ํ•œ๊ธ€ ์ƒ‰์ƒ + /\s*๋ธ”๋ž™\s*/gi, + /\s*ํ™”์ดํŠธ\s*/gi, + /\s*์‹ค๋ฒ„\s*/gi, + /\s*๊ณจ๋“œ\s*/gi, + /\s*๊ทธ๋ ˆ์ด\s*/gi, + /\s*๊ทธ๋ฆฐ\s*/gi, + /\s*๋ธ”๋ฃจ\s*/gi, + /\s*๋ ˆ๋“œ\s*/gi, + /\s*ํ•‘ํฌ\s*/gi, + /\s*ํผํ”Œ\s*/gi, + /\s*์˜๋กœ์šฐ\s*/gi, + /\s*์˜ค๋ Œ์ง€\s*/gi, + /\s*๋ธŒ๋ผ์šด\s*/gi, + /\s*๋„ค์ด๋น„\s*/gi, + /\s*์ŠคํŽ˜์ด์Šค\s*๊ทธ๋ ˆ์ด\s*/gi, + /\s*๋ฏธ๋“œ๋‚˜์ดํŠธ\s*/gi, + /\s*์Šคํƒ€๋ผ์ดํŠธ\s*/gi, + + // ์˜์–ด ์ƒ‰์ƒ + /\s*Black\s*/gi, + /\s*White\s*/gi, + /\s*Silver\s*/gi, + /\s*Gold\s*/gi, + /\s*Gray\s*/gi, + /\s*Grey\s*/gi, + /\s*Green\s*/gi, + /\s*Blue\s*/gi, + /\s*Red\s*/gi, + /\s*Pink\s*/gi, + /\s*Purple\s*/gi, + /\s*Yellow\s*/gi, + /\s*Orange\s*/gi, + /\s*Brown\s*/gi, + /\s*Navy\s*/gi, + /\s*Space\s*Gray\s*/gi, + /\s*Midnight\s*/gi, + /\s*Starlight\s*/gi, + /\s*Rose\s*Gold\s*/gi, + /\s*๋กœ์ฆˆ\s*๊ณจ๋“œ\s*/gi, + + // ๊ด„ํ˜ธ๋กœ ๊ฐ์‹ธ์ง„ ์ƒ‰์ƒ (์˜ˆ: "(๋ธ”๋ž™)", "(Black)") + /\s*\([^)]*\)\s*/g, + ]; + + colorPatterns.forEach(pattern => { + baseName = baseName.replace(pattern, ' '); + }); + + // 3. ์–‘ ๋ ๊ณต๋ฐฑ ์ œ๊ฑฐ ๋ฐ ์—ฐ์†๋œ ๊ณต๋ฐฑ์„ ํ•˜๋‚˜๋กœ + baseName = baseName.trim().replace(/\s+/g, ' '); + + return baseName; +}; diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 00000000..c802f1b3 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,18 @@ +import axios from 'axios'; + +type ApiErrorBody = { message?: string }; + +export function parseApiError(error: unknown): { + hasResponse: boolean; + message?: string; +} { + // axios ์—๋Ÿฌ๊ฐ€ ์•„๋‹ˆ๋ฉด response๊ฐ€ ์žˆ๋‹ค๊ณ  ๋‹จ์ •ํ•  ์ˆ˜ ์—†์Œ + if (!axios.isAxiosError(error)) { + return { hasResponse: false }; + } + + return { + hasResponse: !!error.response, + message: error.response?.data?.message, + }; +} diff --git a/src/utils/finalizeLogin.ts b/src/utils/finalizeLogin.ts new file mode 100644 index 00000000..14730931 --- /dev/null +++ b/src/utils/finalizeLogin.ts @@ -0,0 +1,27 @@ +import { setAccessToken } from '@/utils/authStorage'; +import { getUserProfile } from '@/apis/mypage/getUserProfile'; +import { queryKey } from '@/constants/queryKey'; +import type { QueryClient } from '@tanstack/react-query'; + +/** + * ๋กœ๊ทธ์ธ ์™„๋ฃŒ ํ›„ ๊ณตํ†ต ์ฒ˜๋ฆฌ ํ•จ์ˆ˜ + * - ํ† ํฐ ์ €์žฅ (keepLogin์— ๋”ฐ๋ผ localStorage ๋˜๋Š” sessionStorage) + * - ์œ ์ € ์ •๋ณด ์กฐํšŒ ๋ฐ ์บ์‹œ ์ €์žฅ + * + * @param accessToken - ์•ก์„ธ์Šค ํ† ํฐ + * @param queryClient - React Query ํด๋ผ์ด์–ธํŠธ + * @param keepLogin - true: localStorage(์˜์†), false: sessionStorage(์„ธ์…˜) + * @throws ์œ ์ € ์ •๋ณด ์กฐํšŒ ์‹คํŒจ ์‹œ ์—๋Ÿฌ ๋ฐœ์ƒ + */ +export const finalizeLogin = async ( + accessToken: string, + queryClient: QueryClient, + keepLogin: boolean +): Promise => { + // 1. ํ† ํฐ ์ €์žฅ (refreshToken์€ httpOnly ์ฟ ํ‚ค๋กœ ๊ด€๋ฆฌ) + setAccessToken(accessToken, keepLogin); + + // 2. ์œ ์ € ์ •๋ณด ์กฐํšŒ ๋ฐ ์บ์‹œ ์ €์žฅ + const userProfile = await getUserProfile(); + queryClient.setQueryData([queryKey.USER_PROFILE], userProfile); +}; diff --git a/src/utils/finalizeLogout.ts b/src/utils/finalizeLogout.ts new file mode 100644 index 00000000..a9ad086e --- /dev/null +++ b/src/utils/finalizeLogout.ts @@ -0,0 +1,20 @@ +import { clearAccessToken } from '@/utils/authStorage'; +import type { QueryClient } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +/* +๋กœ๊ทธ์•„์›ƒ ์ฒ˜๋ฆฌ ํ•จ์ˆ˜ + +1. ํ† ํฐ ์ œ๊ฑฐ +2. ์œ ์ €๋ณ„ ๋ฐ์ดํ„ฐ ์บ์‹œ ์‚ญ์ œ (์กฐํ•ฉ ๋ชฉ๋ก, ์กฐํ•ฉ ์ƒ์„ธ, ์œ ์ € ์ •๋ณด) +*/ + +export const finalizeLogout = (queryClient: QueryClient): void => { + // 1. ํ† ํฐ ์ œ๊ฑฐ + clearAccessToken(); + + // 2. ์œ ์ €๋ณ„ ๋ฐ์ดํ„ฐ ์บ์‹œ ์‚ญ์ œ + queryClient.removeQueries({ queryKey: [queryKey.COMBOS] }); + queryClient.removeQueries({ queryKey: [queryKey.COMBO_DETAIL] }); + queryClient.removeQueries({ queryKey: [queryKey.USER_PROFILE] }); +}; diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 00000000..e9cb32e7 --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,23 @@ +/** + * ์ดˆ ๋‹จ์œ„ ์ˆซ์ž๋ฅผ mm:ss ํ˜•์‹ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ + * @param seconds - ์ดˆ ๋‹จ์œ„ ์‹œ๊ฐ„ (0 ์ด์ƒ) + * @returns "MM:SS" ํ˜•์‹ ๋ฌธ์ž์—ด + */ +export function formatTime(seconds: number): string { + const min = Math.floor(seconds / 60); + const sec = seconds % 60; + return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; +} + +/** + * ISO ๋‚ ์งœ ๋ฌธ์ž์—ด์„ YYYY.MM.DD ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ + * @param isoDate - ISO 8601 ํ˜•์‹ ๋‚ ์งœ ๋ฌธ์ž์—ด + * @returns "YYYY.MM.DD" ํ˜•์‹ ๋ฌธ์ž์—ด + */ +export function formatDate(isoDate: string): string { + const date = new Date(isoDate); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}.${month}.${day}`; +} diff --git a/src/utils/mapEvaluationToUI.ts b/src/utils/mapEvaluationToUI.ts new file mode 100644 index 00000000..3a5a7c72 --- /dev/null +++ b/src/utils/mapEvaluationToUI.ts @@ -0,0 +1,54 @@ +import type { ComboEvaluationResult } from '@/types/combo/evaluation'; +import type { Grade } from '@/constants/evaluation/grade'; +import type { EvaluationCardData } from '@/constants/evaluation/connectivity'; +import type { LifestyleKey } from '@/constants/evaluation/lifestyle'; +import { toGrade } from '@/constants/evaluation/grade'; +import { CONNECTIVITY } from '@/constants/evaluation/connectivity'; +import { CONVENIENCE } from '@/constants/evaluation/convenience'; +import { LIFESTYLE } from '@/constants/evaluation/lifestyle'; + +// UI ์นด๋“œ์— ์ „๋‹ฌํ•  props ํƒ€์ž… +export type EvaluationCardUI = { + category: string; // '์—ฐ๋™์„ฑ' | 'ํŽธ์˜์„ฑ' | '๋ผ์ดํ”„์Šคํƒ€์ผ' + grade: Grade; + tags: string[]; + text: string; +}; + +// API ์‘๋‹ต โ†’ UI ์นด๋“œ props ๋ณ€ํ™˜ +export const mapEvaluationToUI = ( + evaluation: ComboEvaluationResult, + lifestyleTagKey?: LifestyleKey +): EvaluationCardUI[] => { + const connectivityGrade = toGrade(evaluation.connectivityGrade); + const convenienceGrade = toGrade(evaluation.convenienceGrade); + const lifestyleGrade = toGrade(evaluation.lifestyleGrade); + + const connectivityData: EvaluationCardData = CONNECTIVITY[connectivityGrade]; + const convenienceData: EvaluationCardData = CONVENIENCE[convenienceGrade]; + + const lifestyleData = lifestyleTagKey + ? LIFESTYLE[lifestyleTagKey]?.[lifestyleGrade] + : undefined; + + return [ + { + category: '์—ฐ๋™์„ฑ', + grade: connectivityGrade, + ...connectivityData, + }, + { + category: 'ํŽธ์˜์„ฑ', + grade: convenienceGrade, + ...convenienceData, + }, + { + category: '๋ผ์ดํ”„์Šคํƒ€์ผ', + grade: lifestyleGrade, + tags: lifestyleTagKey + ? [`#${lifestyleTagKey}`, ...(lifestyleData?.tags ?? [])] + : ['-'], + text: lifestyleData?.text ?? '-', + }, + ]; +}; diff --git a/src/utils/mapSearchDevice.ts b/src/utils/mapSearchDevice.ts new file mode 100644 index 00000000..f002bf06 --- /dev/null +++ b/src/utils/mapSearchDevice.ts @@ -0,0 +1,22 @@ +import type { SearchDevice } from '@/types/devices'; +import type { Product } from '@/types/product'; + +// SearchDevice๋ฅผ Product ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ +export const mapSearchDeviceToProduct = (device: SearchDevice): Product => { + const brandName = device.brandName ?? ''; + const deviceName = device.name ?? ''; + + // device.name์ด ์ด๋ฏธ brandName์œผ๋กœ ์‹œ์ž‘ํ•˜๋ฉด ์ค‘๋ณต ๋ฐฉ์ง€ + const fullName = deviceName.startsWith(brandName) + ? deviceName + : `${brandName} ${deviceName}`.trim(); + + return { + id: device.deviceId, + name: fullName, + category: device.deviceType ?? '', + price: device.price ?? 0, + image: device.imageUrl ?? null, + colors: [] as string[], + }; +}; diff --git a/src/utils/nextInArray.ts b/src/utils/nextInArray.ts new file mode 100644 index 00000000..058db15b --- /dev/null +++ b/src/utils/nextInArray.ts @@ -0,0 +1,5 @@ +export const nextInArray = (arr: readonly T[], current: T) => { + const idx = arr.indexOf(current); + const safeIdx = idx === -1 ? 0 : idx; + return arr[(safeIdx + 1) % arr.length]; +}; diff --git a/src/utils/setAuthorizationHeader.ts b/src/utils/setAuthorizationHeader.ts new file mode 100644 index 00000000..21c5e119 --- /dev/null +++ b/src/utils/setAuthorizationHeader.ts @@ -0,0 +1,20 @@ +import type { InternalAxiosRequestConfig } from 'axios'; + +// ํ† ํฐ์„ ํ—ค๋”์— ์ถ”๊ฐ€ํ•˜๋Š” ํ•จ์ˆ˜ +export const setAuthorizationHeader = ( + config: InternalAxiosRequestConfig, + token: string +) => { + const value = `Bearer ${token}`; + + // AxiosHeaders ์ธ์Šคํ„ด์Šค๋ฉด set ์‚ฌ์šฉ + if (config.headers && typeof (config.headers as any).set === 'function') { + (config.headers as any).set('Authorization', value); + return; + } + + // plain object๋ฉด ๊ธฐ์กด ๋ฐฉ์‹ + config.headers = config.headers ?? {}; + (config.headers as any).Authorization = value; +}; + diff --git a/src/utils/splitTags.ts b/src/utils/splitTags.ts new file mode 100644 index 00000000..043c31ae --- /dev/null +++ b/src/utils/splitTags.ts @@ -0,0 +1,35 @@ +// ํƒœ๊ทธ ๊ทธ๋ฃน ๋ถ„๋ฅ˜ ํ•จ์ˆ˜ +// - ์ž…๋ ฅ: ์ „์ฒด ํƒœ๊ทธ ๋ชฉ๋ก +// - ์ถœ๋ ฅ: TagGroups (4๊ฐœ ๊ทธ๋ฃน์œผ๋กœ ๋ถ„๋ฅ˜๋œ ๊ฐ์ฒด) + +import type { Tag } from '@/types/tag/tag'; +import { TAG_GROUP_BY_KEY } from '@/constants/tagGroup'; + +// ์˜จ๋ณด๋”ฉ ํƒœ๊ทธ ๊ทธ๋ฃน ํƒ€์ž… +export type TagGroups = { + interest: Tag[]; + lifestyle: Tag[]; + brand: Tag[]; + unknown: Tag[]; // ํ˜น์‹œ ์ƒˆ ํƒœ๊ทธ ๋“ค์–ด์™”๋Š”๋ฐ ๋ถ„๋ฅ˜ ๋ชปํ•˜๋ฉด ์—ฌ๊ธฐ๋กœ +}; + +// ํƒœ๊ทธ ๊ทธ๋ฃน ๋ถ„๋ฅ˜ ํ•จ์ˆ˜ +export const splitTags = (tags: Tag[]): TagGroups => { + const grouped: TagGroups = { + interest: [], + lifestyle: [], + brand: [], + unknown: [], + }; + + for (const tag of tags) { + const key = tag.tagKey; + + if (TAG_GROUP_BY_KEY.interest.has(key)) grouped.interest.push(tag); + else if (TAG_GROUP_BY_KEY.lifestyle.has(key)) grouped.lifestyle.push(tag); + else if (TAG_GROUP_BY_KEY.brand.has(key)) grouped.brand.push(tag); + else grouped.unknown.push(tag); + } + + return grouped; +}; diff --git a/src/utils/tag/deviceLifestyleTags.ts b/src/utils/tag/deviceLifestyleTags.ts new file mode 100644 index 00000000..77c54b48 --- /dev/null +++ b/src/utils/tag/deviceLifestyleTags.ts @@ -0,0 +1,107 @@ +import type { SearchDevice } from '@/types/devices'; + +/** + * ๊ธฐ๊ธฐ ์ƒ์„ธ ์ •๋ณด์˜ specifications๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ๋ผ์ดํ”„์Šคํƒ€์ผ ์Šคํƒ€์ผ ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + */ +export const getDeviceLifestyleTags = (device: SearchDevice): string[] => { + const { deviceType, specifications: specs } = device; + if (!specs) return []; + + const tags: string[] = []; + + switch (deviceType) { + case 'SMARTPHONE': + case 'TABLET': + if (specs.storageGb) tags.push(`${specs.storageGb}GB`); + if (specs.screenInch) tags.push(`${specs.screenInch}์ธ์น˜`); + break; + + case 'LAPTOP': + if (specs.cpu) tags.push(String(specs.cpu)); + if (specs.screenInch) tags.push(`${specs.screenInch}์ธ์น˜`); + break; + + case 'SMARTWATCH': + if (specs.caseSizeMm) tags.push(`${specs.caseSizeMm}mm`); + if (specs.hasCellular !== undefined) { + // 1์ด๋ฉด ์…€๋ฃฐ๋Ÿฌ, 0์ด๋ฉด GPS + tags.push(Number(specs.hasCellular) === 1 ? '์…€๋ฃฐ๋Ÿฌ' : 'GPS'); + } + break; + + case 'AUDIO': + if (Number(specs.hasAnc) === 1) tags.push('ANC'); + if (specs.totalBatteryLifeHours) tags.push(`${specs.totalBatteryLifeHours}์‹œ๊ฐ„`); + break; + + case 'KEYBOARD': + if (specs.switchType) { + const switchMap: Record = { + BLUE: '์ฒญ์ถ•', + RED: '์ ์ถ•', + BROWN: '๊ฐˆ์ถ•', + SCISSOR: 'ํŽœํƒ€๊ทธ๋ž˜ํ”„', + }; + const mapped = switchMap[String(specs.switchType).toUpperCase()]; + if (mapped) tags.push(mapped); + } + if (specs.connectionType) { + tags.push(formatConnectionType(String(specs.connectionType))); + } + break; + + case 'MOUSE': + if (specs.mouseType) { + const mouseMap: Record = { + VERTICAL: '๋ฒ„ํ‹ฐ์ปฌ', + TRACKBALL: 'ํŠธ๋ž™๋ณผ', + }; + const mapped = mouseMap[String(specs.mouseType).toUpperCase()]; + if (mapped) tags.push(mapped); + } + if (specs.connectionType) { + tags.push(formatConnectionType(String(specs.connectionType))); + } + break; + + case 'CHARGER': + if (specs.totalPowerW) tags.push(`${specs.totalPowerW}W`); + if (Array.isArray(specs.portConfiguration)) { + const portConfig = formatPortConfiguration(specs.portConfiguration); + if (portConfig) tags.push(portConfig); + } + break; + + default: + break; + } + + return tags.filter(Boolean); +}; + +const formatConnectionType = (type: string): string => { + const t = type.toUpperCase(); + if (t === 'BLUETOOTH') return '๋ธ”๋ฃจํˆฌ์Šค'; + if (t === 'WIRED') return '์œ ์„ '; + // DONGLE์ด๋‚˜ ๊ธฐํƒ€ ๋ฌด์„  ๋ฐฉ์‹์€ '๋ฌด์„ '์œผ๋กœ ํ‘œ๊ธฐ + return '๋ฌด์„ '; +}; + +const formatPortConfiguration = (ports: string[]): string => { + let cCount = 0; + let aCount = 0; + + ports.forEach((port) => { + const p = port.toUpperCase(); + if (p.includes('USB_C')) cCount++; + else if (p.includes('USB_A')) aCount++; + else if (p.includes('C')) cCount++; + else if (p.includes('A')) aCount++; + }); + + const result = []; + if (cCount > 0) result.push(`${cCount}C`); + if (aCount > 0) result.push(`${aCount}A`); + + return result.join(''); +}; diff --git a/src/utils/validateNickname.ts b/src/utils/validateNickname.ts new file mode 100644 index 00000000..1c02ff1b --- /dev/null +++ b/src/utils/validateNickname.ts @@ -0,0 +1,37 @@ +export const validateNickname = (nickname: string) => { + if (nickname.length === 0) { + return '๋‹‰๋„ค์ž„์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'; + } + + if (nickname.trim().length === 0) { + return '๊ณต๋ฐฑ๋งŒ ์ž…๋ ฅํ•  ์ˆ˜ ์—†์–ด์š”.'; + } + + if (nickname !== nickname.trim()) { + return '๋‹‰๋„ค์ž„์€ ๊ณต๋ฐฑ์œผ๋กœ ์‹œ์ž‘ํ•˜๊ฑฐ๋‚˜ ๋๋‚  ์ˆ˜ ์—†์–ด์š”.'; + } + + const allowedRegex = /^[๊ฐ€-ํžฃa-zA-Z ]+$/; + if (!allowedRegex.test(nickname)) { + return '๋‹‰๋„ค์ž„์€ ํ•œ๊ธ€, ์˜์–ด, ๊ณต๋ฐฑ๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด์š”.'; + } + const length = nickname.length; + const hasKorean = /[๊ฐ€-ํžฃ]/.test(nickname); + const hasEnglish = /[a-zA-Z]/.test(nickname); + + if (hasKorean && hasEnglish) { + if (length > 6) { + return 'ํ•œ๊ธ€๊ณผ ์˜์–ด๋ฅผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ๊ฒฝ์šฐ ์ตœ๋Œ€ 6์ž๊นŒ์ง€ ๊ฐ€๋Šฅํ•ด์š”.'; + } + } else if (hasKorean) { + if (length > 5) { + return 'ํ•œ๊ธ€ ๋‹‰๋„ค์ž„์€ ์ตœ๋Œ€ 5์ž๊นŒ์ง€ ๊ฐ€๋Šฅํ•ด์š”.'; + } + } else if (hasEnglish) { + if (length > 7) { + return '์˜์–ด ๋‹‰๋„ค์ž„์€ ์ตœ๋Œ€ 7์ž๊นŒ์ง€ ๊ฐ€๋Šฅํ•ด์š”.'; + } + } + + return ''; +}; diff --git a/src/utils/validatePassword.ts b/src/utils/validatePassword.ts new file mode 100644 index 00000000..651b4b78 --- /dev/null +++ b/src/utils/validatePassword.ts @@ -0,0 +1,15 @@ +const PASSWORD_REGEX = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,20}$/; + +export const validateNewPassword = (password: string) => { + if (password.length === 0) return ''; + if (!PASSWORD_REGEX.test(password)) { + return '์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8~20์ž, ์˜๋ฌธ+์ˆซ์ž๋ฅผ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.'; + } + return ''; +}; + +export const validatePasswordConfirm = (newPassword: string, confirmPassword: string) => { + if (confirmPassword.length === 0) return ''; + if (newPassword !== confirmPassword) return '์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'; + return ''; +}; diff --git a/tsconfig.app.json b/tsconfig.app.json index a1323130..00258e60 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -5,7 +5,7 @@ "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": ["vite/client"], + "types": ["vite/client", "vite-plugin-svgr/client"], "skipLibCheck": true, /* Bundler mode */ diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..3a48e56b --- /dev/null +++ b/vercel.json @@ -0,0 +1,3 @@ +{ + "rewrites": [{ "source": "/(.*)", "destination": "/" }] +} diff --git a/vite.config.ts b/vite.config.ts index 0e1b4b44..6758f64c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,13 +1,27 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +import tailwindcss from '@tailwindcss/vite'; +import svgr from 'vite-plugin-svgr'; // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss(), svgr()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules')) { + return id.toString().split('node_modules/')[1].split('/')[0].toString(); + } + }, + }, + }, + chunkSizeWarningLimit: 2000, + }, });