diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index ab17bac..9f6dd4c 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -25,6 +25,11 @@ jobs: id: install run: npm ci + - name: Create .env file + run: | + echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" >> .env + echo "NEXT_TOKEN_MAX_AGE=${{ secrets.NEXT_TOKEN_MAX_AGE }}" >> .env + - name: Lint check id: lint continue-on-error: true diff --git a/package-lock.json b/package-lock.json index fb123c0..ba06666 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,18 +12,24 @@ "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@svgr/webpack": "^8.1.0", + "@tanstack/react-query": "^5.66.7", + "@tanstack/react-query-devtools": "^5.66.7", "autoprefixer": "^10.4.20", + "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.475.0", "next": "14.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.54.2", "tailwind-merge": "^3.0.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zustand": "^5.0.3" }, "devDependencies": { "@eslint/eslintrc": "^3.0.0", + "@tanstack/eslint-plugin-query": "^5.66.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", @@ -3627,6 +3633,229 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.66.1", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.66.1.tgz", + "integrity": "sha512-pYMVTGgJ7yPk9Rm6UWEmbY6TX0EmMmxJqYkthgeDCwEznToy2m+W928nUODFirtZBZlhBsqHy33LO0kyTlgf0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.18.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/scope-manager": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.1.tgz", + "integrity": "sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/types": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.1.tgz", + "integrity": "sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.1.tgz", + "integrity": "sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/utils": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.1.tgz", + "integrity": "sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/typescript-estree": "8.24.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.1.tgz", + "integrity": "sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/ts-api-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.66.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.4.tgz", + "integrity": "sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.65.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.65.0.tgz", + "integrity": "sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.66.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.7.tgz", + "integrity": "sha512-qd3q/tUpF2K1xItfPZddk1k/8pSXnovg41XyCqJgPoyYEirMBtB0sVEVVQ/CsAOngzgWtBPXimVf4q4kM9uO6A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.66.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.66.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.66.7.tgz", + "integrity": "sha512-40z4PPkz06tYIF0vwLZZIZfZxKUH4OAaBOR14blCFyYm6hlU6qc+M82mkZ+D00HcEMhV7P4XeJiEuDhFq0q9Qw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.65.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.66.7", + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -4547,8 +4776,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/autoprefixer": { "version": "10.4.20", @@ -4611,6 +4839,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -5266,7 +5505,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -5653,7 +5891,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -6986,6 +7223,26 @@ "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.4.tgz", @@ -7020,7 +7277,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -9818,7 +10074,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -9827,7 +10082,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -10808,6 +11062,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "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/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -10893,6 +11153,22 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "license": "MIT", + "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-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -13072,6 +13348,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", + "integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==", + "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 1fff363..fdd8cf7 100644 --- a/package.json +++ b/package.json @@ -17,18 +17,24 @@ "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@svgr/webpack": "^8.1.0", + "@tanstack/react-query": "^5.66.7", + "@tanstack/react-query-devtools": "^5.66.7", "autoprefixer": "^10.4.20", + "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.475.0", "next": "14.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.54.2", "tailwind-merge": "^3.0.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zustand": "^5.0.3" }, "devDependencies": { "@eslint/eslintrc": "^3.0.0", + "@tanstack/eslint-plugin-query": "^5.66.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 50071e6..93befb5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import Header from '@/components/common/Header'; +import ReactQueryProviders from '@/hooks/useReactQuery'; import type { Metadata } from 'next'; import localFont from 'next/font/local'; @@ -24,8 +25,10 @@ export default function RootLayout({ return ( -
-
{children}
+ +
+
{children}
+ ); diff --git a/src/app/login/components/DummyUser.tsx b/src/app/login/components/DummyUser.tsx new file mode 100644 index 0000000..530c9ad --- /dev/null +++ b/src/app/login/components/DummyUser.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { Button } from '@/components/ui/Button'; +import { useLoginMutation } from '@/hooks/mutations/useUserMutation'; +import { useRouter } from 'next/navigation'; + +const DummyUser = () => { + const router = useRouter(); + const password = 'test1234'; + + const { mutate } = useLoginMutation({ + onSuccessCallback: () => router.push('/'), + }); + + return ( +
+ + + + +
+ ); +}; + +export default DummyUser; diff --git a/src/app/login/components/LoginForm.tsx b/src/app/login/components/LoginForm.tsx new file mode 100644 index 0000000..bc39a0c --- /dev/null +++ b/src/app/login/components/LoginForm.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { useLoginForm } from '@/hooks/useLoginForm'; +import Link from 'next/link'; + +const LoginForm = () => { + const { register, handleSubmit, onSubmit, errors, setFocusedField } = + useLoginForm(); + return ( +
+
+

로그인

+
+ + setFocusedField('email')} + /> + + setFocusedField('password')} + /> + +
+

비밀번호를 잊으셨나요?

+ + 비밀번호 수정 + +
+
+
+
+ + +
+
+ ); +}; +export default LoginForm; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..93eaed2 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,27 @@ +import { Button } from '@/components/ui/Button'; +import { baseURL } from '@/lib/axios/defaultConfig'; + +import DummyUser from './components/DummyUser'; +import LoginForm from './components/LoginForm'; + +export const metadata = { + metadataBase: new URL(`${baseURL}/login`), + title: '로그인 | Deving', + description: 'Deving에 로그인하고 다양한 서비스를 이용하세요', + openGraph: { + title: '로그인 | Deving', + description: 'Deving에 로그인하고 다양한 서비스를 이용하세요', + url: `${baseURL}/login`, // 추후 수정 + siteName: 'deving', + type: 'website', + }, +}; + +export default function Login() { + return ( +
+ + +
+ ); +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000..df258ab --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import Link from 'next/link'; +import { useForm } from 'react-hook-form'; + +interface ISignupFormData { + id: string; + pw: string; +} + +export default function Signup() { + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + mode: 'onBlur', + }); + const onSubmit = (data: ISignupFormData) => { + console.log('로그인 데이터: ', data); + }; + return ( +
+
+
+

+ 회원가입 +

+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+

비밀번호를 잊으셨나요?

+ + 비밀번호 수정 + +
+
+
+ ); +} diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx index 6418643..b3d4bcf 100644 --- a/src/components/common/Header.tsx +++ b/src/components/common/Header.tsx @@ -167,7 +167,7 @@ const Header = ({ isLogIn = false }) => { - {isLogIn ? : } + {!isLogIn ? : } setIsOpen((prev) => !prev)} @@ -181,7 +181,7 @@ const Header = ({ isLogIn = false }) => { isOpen ? 'translate-x-0' : 'translate-x-full' } lg:hidden`} > - {isLogIn ? : } + {!isLogIn ? : } diff --git a/src/hooks/mutations/useUserMutation.ts b/src/hooks/mutations/useUserMutation.ts new file mode 100644 index 0000000..93e8278 --- /dev/null +++ b/src/hooks/mutations/useUserMutation.ts @@ -0,0 +1,29 @@ +import { setAccessToken } from '@/lib/serverActions'; +import { useMutation } from '@tanstack/react-query'; +import { postLogin } from 'service/api/user'; + +const useLoginMutation = ({ + onSuccessCallback, +}: { + onSuccessCallback: () => void; +}) => { + return useMutation({ + mutationFn: ({ email, password }: { email: string; password: string }) => + postLogin({ email, password }), + onSuccess: async (res) => { + // 쿠키 저장 + const accessToken = res.headers.token; + if (accessToken) { + await setAccessToken(accessToken); + } + + // 메인페이지로 리다이렉트 + onSuccessCallback(); + }, + onError: () => { + console.log('로그인 에러'); + }, + }); +}; + +export { useLoginMutation }; diff --git a/src/hooks/useDebounde.ts b/src/hooks/useDebounde.ts new file mode 100644 index 0000000..f0f587e --- /dev/null +++ b/src/hooks/useDebounde.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; + +/** + * 특정 값이 변경된 후 지정된 시간이 지나면 콜백 함수를 실행하는 Debounce 훅 + * + * @param {T} value - 감지할 값 + * @param {number} delay - 딜레이(ms) (기본값: 1000ms) + * @param {Function} callback - 딜레이 후 실행할 콜백 함수 + */ +const useDebounce = ({ + value, + delay = 1000, + callBack, +}: { + value: T; + delay?: number; + callBack?: () => void; +}) => { + useEffect(() => { + if (!value) return; + const timer = setTimeout(() => { + if (callBack) { + callBack(); + } + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay, callBack]); +}; +export default useDebounce; diff --git a/src/hooks/useLoginForm.ts b/src/hooks/useLoginForm.ts new file mode 100644 index 0000000..f66b93b --- /dev/null +++ b/src/hooks/useLoginForm.ts @@ -0,0 +1,68 @@ +import { useRouter } from 'next/navigation'; +import { useCallback, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; + +import { useLoginMutation } from './mutations/useUserMutation'; +import useDebounce from './useDebounde'; + +interface ILoginFormData { + email: string; + password: string; +} + +export function useLoginForm() { + const { + register, + handleSubmit, + control, + trigger, + formState: { errors }, + } = useForm({ + mode: 'onBlur', + }); + + const router = useRouter(); + const [focusedField, setFocusedField] = useState<'email' | 'password' | null>( + null, + ); + + // `useWatch`를 사용하여 특정 필드만 감시 (렌더링 최소화) + const email = useWatch({ control, name: 'email' }); + const password = useWatch({ control, name: 'password' }); + + // 이메일 포커스 1초 뒤 유효성 검사 + useDebounce({ + value: email, + callBack: useCallback(() => { + if (focusedField === 'email') { + trigger(focusedField); + } + }, [focusedField, trigger]), + }); + + // 비밀번호 포커스 1초 뒤 유효성 검사 + useDebounce({ + value: password, + callBack: useCallback(() => { + if (focusedField === 'password') { + trigger(focusedField); + } + }, [focusedField, trigger]), + }); + + const { mutate } = useLoginMutation({ + onSuccessCallback: () => router.push('/'), + }); + + const onSubmit = async (data: ILoginFormData) => { + mutate(data); + }; + + return { + register, + handleSubmit, + errors, + setFocusedField, + onSubmit, + }; +} diff --git a/src/hooks/useReactQuery.tsx b/src/hooks/useReactQuery.tsx new file mode 100644 index 0000000..04d8333 --- /dev/null +++ b/src/hooks/useReactQuery.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; + +export default function ReactQueryProviders({ + children, +}: React.PropsWithChildren) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, // 창을 다시 활성화할 때 자동으로 refetch 안 함 (옵션) + refetchOnReconnect: true, // 네트워크 재연결 시 refetch + }, + }, + }); + + return ( + + {children} + + + ); +} diff --git a/src/lib/axios/authApi.ts b/src/lib/axios/authApi.ts new file mode 100644 index 0000000..4ae9fa4 --- /dev/null +++ b/src/lib/axios/authApi.ts @@ -0,0 +1,43 @@ +import axios from 'axios'; + +import { getAccessToken, removeAccessToken } from '../serverActions'; +import { defaultConfig } from './defaultConfig'; + +export const authAPI = axios.create(defaultConfig); + +authAPI.interceptors.request.use( + async (config) => { + const accessToken = await getAccessToken(); + if (accessToken) { + config.headers.token = `${accessToken}`; + } + return config; + }, + async (error) => { + /** + * TODO + * 에러 발생했다는 팝업, 모달 추가 + */ + return Promise.reject(error); + }, +); + +authAPI.interceptors.response.use( + (response) => response, + async (error) => { + /** + * TODO:(refresh 토큰 발급 이후) + * - 토근 재발급 로직 + */ + + /** + * - 401 에러로 실패하면, 로그인 페이지로 리다이렉트하는 로직 + * - 리다이렉트 전에 사용자에게 경고 메시지 + */ + if (error.response?.status === 401) { + await removeAccessToken(); + window.location.href = '/login'; + } + return Promise.reject(error); + }, +); diff --git a/src/lib/axios/basicApi.ts b/src/lib/axios/basicApi.ts new file mode 100644 index 0000000..e1d9a24 --- /dev/null +++ b/src/lib/axios/basicApi.ts @@ -0,0 +1,5 @@ +import axios from 'axios'; + +import { defaultConfig } from './defaultConfig'; + +export const basicAPI = axios.create(defaultConfig); diff --git a/src/lib/axios/defaultConfig.ts b/src/lib/axios/defaultConfig.ts new file mode 100644 index 0000000..aa2e131 --- /dev/null +++ b/src/lib/axios/defaultConfig.ts @@ -0,0 +1,12 @@ +import { AxiosRequestConfig } from 'axios'; + +export const baseURL = process.env.NEXT_PUBLIC_API_URL; + +export const defaultConfig: AxiosRequestConfig = { + baseURL, + withCredentials: true, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}; diff --git a/src/lib/serverActions.ts b/src/lib/serverActions.ts new file mode 100644 index 0000000..19a4343 --- /dev/null +++ b/src/lib/serverActions.ts @@ -0,0 +1,26 @@ +'use server'; + +import { cookies } from 'next/headers'; + +export async function getAccessToken() { + const cookieStore = cookies(); + return cookieStore.get('accessToken')?.value || null; +} + +export async function removeAccessToken() { + const cookieStore = cookies(); + cookieStore.delete('accessToken'); +} + +export async function setAccessToken(token: string) { + const cookieStore = cookies(); + const isProd = process.env.NODE_ENV === 'production'; + cookieStore.set('accessToken', token, { + httpOnly: true, + sameSite: 'strict', + path: '/', + secure: isProd, + domain: isProd ? process.env.COOKIE_DOMAIN : undefined, + maxAge: parseInt(process.env.NEXT_TOKEN_MAX_AGE as string) || 60 * 60, + }); +} diff --git a/src/service/api/user.ts b/src/service/api/user.ts new file mode 100644 index 0000000..8836b4c --- /dev/null +++ b/src/service/api/user.ts @@ -0,0 +1,14 @@ +import { basicAPI } from '@/lib/axios/basicApi'; + +const postLogin = async ({ + email, + password, +}: { + email: string; + password: string; +}) => { + const res = await basicAPI.post('/api/v1/auths/login', { email, password }); + + return res; +}; +export { postLogin }; diff --git a/src/styles/globals.css b/src/styles/globals.css index 7f40eb0..d07498b 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -61,7 +61,7 @@ @apply border-border; } body { - @apply bg-background text-foreground; + @apply bg-BG text-foreground; } } */ @@ -73,3 +73,21 @@ @apply bg-background text-foreground; } } + +@layer utilities { + input:-webkit-autofill { + -webkit-box-shadow: 0 0 0px 1000px #22242b inset !important; /* ✅ 배경색 강제 덮어쓰기 */ + -webkit-text-fill-color: #b4bbce; + box-shadow: 0 0 0px 1000px #22242b inset; + transition: background-color 5000s ease-in-out 0s; + } + + input:-webkit-autofill:hover, + input:-webkit-autofill:focus, + input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0px 1000px #22242b inset !important; + -webkit-text-fill-color: #b4bbce; + box-shadow: 0 0 0px 1000px #22242b inset; + transition: background-color 5000s ease-in-out 0s; + } +}