diff --git a/.babelrc b/.babelrc
new file mode 100644
index 000000000..29cac2cd1
--- /dev/null
+++ b/.babelrc
@@ -0,0 +1,4 @@
+{
+ "presets": ["@babel/preset-env", ["@babel/preset-react", { "runtime": "automatic", "importSource": "@emotion/react" }]],
+ "plugins": ["@emotion/babel-plugin", "@babel/plugin-syntax-jsx"]
+}
diff --git a/.gitignore b/.gitignore
index c70d7f329..92dda0c8a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
#manual
.vscode
test.js
+*.http
# dependencies
/node_modules
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..fc259f873
--- /dev/null
+++ b/README.md
@@ -0,0 +1,50 @@
+# ๐ผ ํ๋ค๋ง์ผ ํ๋ก์ ํธ
+
+> _์ด ์ ์ฅ์๋ ํ๋ค๋ง์ผ ํ๋ก์ ํธ์ ํ๋ก ํธ์๋ ์ฝ๋๋ฅผ ๊ด๋ฆฌํ๋ ๊ณณ์
๋๋ค. ํ๋ก์ ํธ๋ฅผ ํด๋ก ํ์ฌ ๊ฐ๋ฐ ํ๊ฒฝ์ ์ค์ ํ๊ณ , ๊ฐ ๋ธ๋์น์์ ํด๋น ์คํ๋ฆฐํธ ๋ฏธ์
์ ์ํํด ์ฃผ์ธ์!_ ๐ ๏ธ
+
+## ์๊ฐ
+
+์๋
ํ์ธ์! ํ๋ค๋ง์ผ ํ๋ก์ ํธ์ ์ค์ ๊ฒ์ ํ์ํฉ๋๋ค! ๐ฅณ
+ํ๋ค๋ง์ผ์ ๋ฐ๋ปํ ์ค๊ณ ๊ฑฐ๋๋ฅผ ์ํ ์ปค๋ฎค๋ํฐ ํ๋ซํผ์ด์์. ์ฌ๋ฌ๋ถ์ ์ด๊ณณ์์ ์ํ์ ๋ฑ๋กํ๊ณ , ๋ค๋ฅธ ์ฌ์ฉ์๋ค๊ณผ ์ํตํ๋ฉฐ, ์์ ๋กญ๊ฒ ์ด์ผ๊ธฐ๋ฅผ ๋๋ ์ ์์ด์. ๋งค์ฃผ ์คํ๋ฆฐํธ ๋ฏธ์
์ ํตํด ๊ธฐ๋ฅ์ ํ๋์ฉ ๋ง๋ค์ด ๊ฐ๋ฉฐ ์ฑ์ฅํด ๋๊ฐ๋ ์ฌ์ ์ ํจ๊ปํด์. ๐
+
+
+_์ ์ด๋ฏธ์ง๋ ํ๋ค๋ง์ผ์ ๋ํ ์ด๋ฏธ์ง์
๋๋ค._ ๐ธ
+
+## ์คํ๋ฆฐํธ ๋ฏธ์
์ด๋? ๐ค
+
+์คํ๋ฆฐํธ ๋ฏธ์
์ **ํ๋์ ๊ฐ์ธ ํ๋ก์ ํธ๋ฅผ ๊ธธ๊ฒ ์งํํ๋ฉด์, ๊ทธ ๊ณผ์ ์์ ์ฃผ๊ธฐ์ ์ผ๋ก ํผ๋๋ฐฑ์ ๋ฐ์ ์ ์๋ ์์คํ
**์ด์์. ๊ฐ ์คํ๋ฆฐํธ๋ง๋ค ๋ฐฐ์ด ์ด๋ก ์ ์ ์ฉํด ๋ณด๊ณ , **๋ฉํ ๋๊ป ์ฝ๋ ๋ฆฌ๋ทฐ๋ฅผ ๋ฐ์๊ฐ๋ฉฐ ์ค๋ ฅ์ ์ฅ์ฅ ํค์๊ฐ ์ ์๋ ์ค์ํ ๊ฐ์ธ ๊ณผ์ **๋๋๋ค. ๐ช
+
+## ์ฃผ์ ๊ธฐ๋ฅ โจ
+
+1. **์ํ ๋ฑ๋ก**: ๋ด๊ฐ ๊ฐ์ง ๋ฌผ๊ฑด์ ์ฌ๋ฆฌ๊ณ , ์ฌ์ง๊ณผ ์ค๋ช
์ ์ถ๊ฐํด ์ง์ ํ๋งคํ ์ ์์ด์!
+2. **๋ฌธ์ ๋๊ธ**: ์ํ์ ๋ํ ๊ถ๊ธํ ์ ์ด๋ ์๊ฒฌ์ ์์ ๋กญ๊ฒ ๋จ๊ธธ ์ ์๋ต๋๋ค. ๐
+3. **์์ ๊ฒ์ํ**: ๋ค์ํ ์ฃผ์ ๋ก ์น๊ตฌ๋ค๊ณผ ์ด์ผ๊ธฐ๋ฅผ ๋๋๊ณ , ์ ๋ณด๋ฅผ ๊ณต์ ํ ์ ์๋ ๊ณต๊ฐ์ด์์! ๐ฃ๏ธ
+
+## ํ๋ก์ ํธ ๋ธ๋์น ๊ตฌ์กฐ ๐๏ธ
+
+ํ๋ก์ ํธ๋ ๋จ๊ณ๋ณ๋ก ๋๋์ด ์๊ณ , ๊ฐ ์คํ๋ฆฐํธ ๋ฏธ์
์ ๋ง๋ ๋ธ๋์น๊ฐ ์์ด์. ๊ฐ ๋ธ๋์น๋ฅผ ํตํด ์ฒด๊ณ์ ์ผ๋ก ๊ฐ๋ฐํ๋ฉฐ ํ์ตํ ์ ์์ด์. ๐ฏ
+
+### ๋ธ๋์น ์ค๋ช
+
+1. **basic (part1): ์คํ๋ฆฐํธ ๋ฏธ์
1 ~ 4 FE ์๊ตฌ์ฌํญ**
+
+ - ๊ธฐ๋ณธ์ ์ธ ์น ์ ํ๋ฆฌ์ผ์ด์
๊ธฐ๋ฅ ๊ตฌํ์ ์ํ ์ด๊ธฐ ๋ธ๋์น์
๋๋ค. HTML, CSS, JavaScript ๋ฑ์ ์ฌ์ฉํด ๊ธฐ๋ณธ์ ๋ค์ง๋๋ค.
+ - **์คํ๋ฆฐํธ ๋ฏธ์
1๋ถํฐ 4๊น์ง**์ ํ๋ก ํธ์๋ ๋ด์ฉ์ ํฌํจํ๊ณ ์์ด์.
+
+2. **react (part2): ์คํ๋ฆฐํธ ๋ฏธ์
5 ~ 7 FE ์๊ตฌ์ฌํญ**
+
+ - React ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํด ํ๋ก ํธ์๋ ๊ธฐ๋ฅ์ ๊ตฌํํ๋ ๋ธ๋์น์
๋๋ค. ์ปดํฌ๋ํธ ๊ธฐ๋ฐ ์ํคํ
์ฒ์ ์ํ ๊ด๋ฆฌ๋ฅผ ๋ฐฐ์๋๋ค.
+ - **์คํ๋ฆฐํธ ๋ฏธ์
5๋ถํฐ 7๊น์ง, ๊ทธ ์ดํ**์ ํ๋ก ํธ์๋ ๋ด์ฉ์ ํฌํจํ๊ณ ์์ด์.
+ - ๋ง์ฝ ์คํ๋ฆฐํธ ๋ฏธ์
9๋ถํฐ ํ๋ก ํธ์๋ ์ฝ๋๋ฅผ Next๊ฐ ์๋ React๋ก ๊ตฌํํ๊ณ ์ถ๋ค๋ฉด react ๋ธ๋์น๋ฅผ ์ฌ์ฉํด์.
+
+3. **next (part3,4): ์คํ๋ฆฐํธ ๋ฏธ์
8 FE ์๊ตฌ์ฌํญ~**
+
+ - Next.js๋ฅผ ์ฌ์ฉํด ์๋ฒ ์ฌ์ด๋ ๋ ๋๋ง(SSR)๊ณผ ์ ์ ์ฌ์ดํธ ์์ฑ(SSG) ๋ฑ ๊ณ ๊ธ ๊ธฐ๋ฅ์ ๊ตฌํํฉ๋๋ค.
+ - **์คํ๋ฆฐํธ ๋ฏธ์
8๋ถํฐ** ์์ํ๋ ํ๋ก ํธ์๋ ๋ด์ฉ์ ํฌํจํ๊ณ ์์ด์.
+ - ๋ง์ฝ ์คํ๋ฆฐํธ ๋ฏธ์
9๋ถํฐ ํ๋ก ํธ์๋ ์ฝ๋๋ฅผ React๊ฐ ์๋ Next๋ก ๊ตฌํํ๊ณ ์ถ๋ค๋ฉด next ๋ธ๋์น๋ฅผ ์ฌ์ฉํด์.
+
+> _์คํ๋ฆฐํธ ๋ฏธ์
๋ด ๋ฐฑ์๋ ์๊ตฌ์ฌํญ์ [๋ฐฑ์๋ ๋ ํฌ์งํ ๋ฆฌ](https://github.com/codeit-sprint-fullstack/2-Sprint-mission-Be)์ ๋ธ๋์น์์ ๊ด๋ฆฌํด์ฃผ์ธ์_
+
+---
+
+๋ณธ ํ๋ก์ ํธ๋ [์ฝ๋์](https://www.codeit.kr)์ ์์ ์ด๋ฉฐ, ๊ต์ก ๋ชฉ์ ์ผ๋ก๋ง ์ฌ์ฉ๋ฉ๋๋ค. ยฉ 2024 Codeit. All rights reserved.
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 000000000..d948f644e
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,170 @@
+import babelParser from '@babel/eslint-parser';
+import pluginJs from '@eslint/js';
+import prettierConfig from 'eslint-config-prettier';
+import pluginImport from 'eslint-plugin-import';
+import pluginJsxA11y from 'eslint-plugin-jsx-a11y';
+import pluginPrettier from 'eslint-plugin-prettier';
+import pluginReact from 'eslint-plugin-react';
+import pluginReactHooks from 'eslint-plugin-react-hooks';
+import globals from 'globals';
+
+export default [
+ { files: ['**/*.{js,mjs,cjs,jsx,ts,tsx}'] },
+ {
+ languageOptions: {
+ globals: { ...globals.browser, ...globals.node },
+ parser: babelParser,
+ parserOptions: {
+ ecmaVersion: 2023, // ECMAScript ๋ฒ์ ์ค์
+ sourceType: 'module',
+ ecmaFeatures: {
+ jsx: true, // JSX ๊ตฌ๋ฌธ ํ์ฑํ
+ },
+ },
+ },
+ },
+ {
+ plugins: {
+ prettier: pluginPrettier,
+ 'jsx-a11y': pluginJsxA11y,
+ react: pluginReact,
+ 'react-hooks': pluginReactHooks,
+ import: pluginImport,
+ },
+ },
+ {
+ settings: {
+ react: {
+ version: 'detect',
+ },
+ 'import/resolver': {
+ node: true,
+ alias: {
+ map: [
+ ['@pages', './pages'],
+ ['@components', './src/components'],
+ ['@contexts', './src/contexts'],
+ ['@hooks', './src/hooks'],
+ ['@layouts', './src/layouts'],
+ ['@utils', './src/utils'],
+ ['@styles', './src/styles'],
+ ['@', './'],
+ ],
+ extensions: ['.js', '.jsx', '.ts', '.tsx'],
+ },
+ },
+ 'import/internal-regex': '@/',
+ },
+ },
+ pluginJs.configs.recommended,
+ prettierConfig,
+ {
+ rules: {
+ 'prettier/prettier': ['error', { endOfLine: 'auto' }],
+ 'no-restricted-globals': 'off',
+ 'no-lone-blocks': 'off',
+ 'no-unused-vars': 'off',
+ 'react/react-in-jsx-scope': 'off', // Next.js์์๋ ํ์ ์์
+ 'react/jsx-uses-react': 'off', // React 17+์์๋ ํ์ ์์
+ 'no-bitwise': 'off',
+ 'react/prop-types': 'off',
+ 'consistent-return': 'off',
+ 'jsx-a11y/click-events-have-key-events': 'off',
+ 'jsx-a11y/no-static-element-interactions': 'off',
+ 'jsx-a11y/no-noninteractive-element-interactions': 'off',
+ 'jsx-a11y/label-has-associated-control': ['error', { some: ['nesting', 'id'] }],
+ 'guard-for-in': 'off',
+ 'no-underscore-dangle': 'off',
+ camelcase: 'off',
+ // NOTE JS/TS ๊ด๋ จ ํ์ฅ์๋ง ์๋ตํ๋ค.
+ 'import/extensions': ['error', { js: 'never', jsx: 'never', ts: 'never', tsx: 'never', css: 'always' }],
+ 'import/no-duplicates': ['warn', { 'prefer-inline': true, considerQueryString: true }],
+ 'import/order': [
+ 'warn',
+ {
+ groups: ['builtin', 'external', 'internal'],
+ 'newlines-between': 'never',
+ distinctGroup: false,
+ pathGroups: [
+ {
+ pattern: '@emotion/**',
+ group: 'external',
+ position: 'before',
+ },
+ {
+ pattern: '@tanstack/**',
+ group: 'external',
+ position: 'before',
+ },
+ {
+ pattern: 'next/**',
+ group: 'external',
+ position: 'before',
+ },
+ {
+ pattern: 'react',
+ group: 'external',
+ position: 'before',
+ },
+ {
+ pattern: 'axios',
+ group: 'external',
+ position: 'before',
+ },
+ {
+ pattern: '@pages/**',
+ group: 'internal',
+ position: 'before',
+ },
+ {
+ pattern: '@components/**',
+ group: 'internal',
+ position: 'before',
+ },
+ {
+ pattern: '@contexts/**',
+ group: 'internal',
+ position: 'before',
+ },
+ {
+ pattern: '@hooks/**',
+ group: 'internal',
+ position: 'before',
+ },
+ {
+ pattern: '@layouts/**',
+ group: 'internal',
+ position: 'before',
+ },
+ {
+ pattern: '@utils/**',
+ group: 'internal',
+ position: 'before',
+ },
+ {
+ pattern: '@styles/**',
+ group: 'internal',
+ position: 'after',
+ },
+ {
+ pattern: '@/**',
+ group: 'internal',
+ position: 'after',
+ },
+ {
+ pattern: './**',
+ group: 'internal',
+ position: 'after',
+ },
+ ],
+ pathGroupsExcludedImportTypes: ['builtin'],
+ warnOnUnassignedImports: true,
+ alphabetize: {
+ order: 'asc',
+ caseInsensitive: false,
+ },
+ },
+ ],
+ },
+ },
+];
diff --git a/jsconfig.json b/jsconfig.json
index e8c61a19b..bd739d648 100644
--- a/jsconfig.json
+++ b/jsconfig.json
@@ -1,12 +1,29 @@
{
- "include": ["**/*.js"],
"compilerOptions": {
+ // NOTE ๋ฃจํธํด๋๋ฅผ @๋ก ์ง์
"paths": {
+ "@pages/*": ["./pages/*"],
+ "@components/*": ["./src/components/*"],
+ "@contexts/*": ["./src/contexts/*"],
+ "@hooks/*": ["./src/hooks/*"],
+ "@layouts/*": ["./src/layouts/*"],
+ "@utils/*": ["./src/utils/*"],
+ "@styles/*": ["./src/styles/*"],
"@/*": ["./*"]
},
- "module": "ESNext",
+ // NOTE ESNext: ECMAScript์ ์ต์ ๋ฒ์ ์ ์ง์
"target": "ESNext",
- "allowJs": true,
- "jsx": "preserve" // React ํ๋ก์ ํธ์ธ ๊ฒฝ์ฐ
- }
+ "module": "ESNext",
+ // NOTE bundler์ ์ต์ ํ๋ ๋ชจ๋ ํด์ ๋ฐฉ์์ ์ง์ . esbuild, webpack ๋ฑ๋ฑ
+ "moduleResolution": "bundler",
+ // NOTE CommonJs ๋ชจ๋์ ES6+ ๋ชจ๋์ฒ๋ผ importํด์ ์ฌ์ฉํ ์ ์๊ฒ ํด์ค.
+ "esModuleInterop": true,
+ // NOTE jsx ํ์ผ์ ์ฒ๋ฆฌ ๋ฐฉ์. preserve๋ก ์ค์ ํ๋ฉด ๋ฒ๋ค๋ฌ๊ฐ ์ฒ๋ฆฌํ๊ฒ ๋ด๋ฒ๋ ค๋ .
+ "jsx": "react-jsx",
+ "checkJs": false
+ },
+ // NOTE ํ๋ก์ ํธ์ ํฌํจํ ํจํด ์ง์
+ "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "node_modules/@types/react/index.d.ts"],
+ // NOTE ํ๋ก์ ํธ์ ์ ์ธํ ํจํด ์ง์
+ "exclude": ["node_modules", "dist"]
}
diff --git a/next.config.mjs b/next.config.mjs
index d0853cc8f..f5ed3a3a6 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -7,6 +7,20 @@ const nextConfig = {
experimental: {
esmExternals: true,
},
+ images: {
+ domains: [
+ 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com',
+ 'example.com',
+ 'www.shutterstock.com',
+ 'i.imgur.com',
+ 'youtube.com',
+ 'pbs.twimg.com',
+ 'cdnb.artstation.com',
+ 'cafe24.poxo.com',
+ 'image.hanatour.com',
+ 'images.samsung.com',
+ ],
+ },
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index 4cd5ac5a9..c114fac32 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,9 @@
"@emotion/cache": "^11.13.1",
"@emotion/react": "^11.13.3",
"@emotion/server": "^11.11.0",
+ "@tanstack/react-query": "^5.59.19",
"axios": "^1.7.7",
+ "jsonwebtoken": "^9.0.2",
"next": "^15.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -24,8 +26,12 @@
"@babel/preset-react": "^7.25.9",
"@emotion/babel-plugin": "^11.12.0",
"@eslint/js": "^9.13.0",
+ "@tanstack/react-query-devtools": "^5.59.19",
+ "@trivago/prettier-plugin-sort-imports": "^4.3.0",
+ "@types/react": "^18.3.12",
"eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0",
+ "eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.1",
"eslint-plugin-prettier": "^5.2.1",
@@ -239,6 +245,46 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
+ "node_modules/@babel/helper-environment-visitor": {
+ "version": "7.24.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz",
+ "integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.24.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-function-name": {
+ "version": "7.24.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz",
+ "integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.24.7",
+ "@babel/types": "^7.24.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-hoist-variables": {
+ "version": "7.24.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz",
+ "integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.24.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz",
@@ -371,6 +417,19 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-split-export-declaration": {
+ "version": "7.24.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz",
+ "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.24.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
@@ -429,9 +488,9 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.26.0",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.0.tgz",
- "integrity": "sha512-aP8x5pIw3xvYr/sXT+SEUwyhrXT8rUJRZltK/qN3Db80dcKpTett8cJxHyjk+xYSVXvNnl2SfcJVjbwxpOSscA==",
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz",
+ "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.0"
@@ -2633,6 +2692,203 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.59.17",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.17.tgz",
+ "integrity": "sha512-jWdDiif8kaqnRGHNXAa9CnudtxY5v9DUxXhodgqX2Rwzj+1UwStDHEbBd9IA5C7VYAaJ2s+BxFR6PUBs8ERorA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/query-devtools": {
+ "version": "5.59.19",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.59.19.tgz",
+ "integrity": "sha512-Gw+3zsADpqiYgx/6MMr9bP1+x2LR8vOuGjo5Un/89qwwP3z7WAHPWFagLFDYkLq68NX7ekUpW/EOYlUMugMXGA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.59.19",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.19.tgz",
+ "integrity": "sha512-xLRfyFyQOFcLltKCds0LijfC6/HQJrrTTnZB8ciyn74LIkVAm++vZJ6eUVG20RmJtdP8REdy7vSOYW4M3//XLA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.59.17"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@tanstack/react-query-devtools": {
+ "version": "5.59.19",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.59.19.tgz",
+ "integrity": "sha512-mYFWTHLtJr2HdyYPZPzzvQ2ksCsSL6L04fCtusPFD3waskXrtmvWvyuDIGeEGdVAYS0Urwxw/0sYvcTVQZH+zQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-devtools": "5.59.19"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "@tanstack/react-query": "^5.59.19",
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@trivago/prettier-plugin-sort-imports": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.3.0.tgz",
+ "integrity": "sha512-r3n0onD3BTOVUNPhR4lhVK4/pABGpbA7bW3eumZnYdKaHkf1qEC+Mag6DPbGNuuh0eG8AaYj+YqmVHSiGslaTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/generator": "7.17.7",
+ "@babel/parser": "^7.20.5",
+ "@babel/traverse": "7.23.2",
+ "@babel/types": "7.17.0",
+ "javascript-natural-sort": "0.7.1",
+ "lodash": "^4.17.21"
+ },
+ "peerDependencies": {
+ "@vue/compiler-sfc": "3.x",
+ "prettier": "2.x - 3.x"
+ },
+ "peerDependenciesMeta": {
+ "@vue/compiler-sfc": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/generator": {
+ "version": "7.17.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.7.tgz",
+ "integrity": "sha512-oLcVCTeIFadUoArDTwpluncplrYBmTCCZZgXCbgNGvOBBiSDDK3eWO4b/+eOTli5tKv1lg+a5/NAXg+nTcei1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.17.0",
+ "jsesc": "^2.5.1",
+ "source-map": "^0.5.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/traverse": {
+ "version": "7.23.2",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz",
+ "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.22.13",
+ "@babel/generator": "^7.23.0",
+ "@babel/helper-environment-visitor": "^7.22.20",
+ "@babel/helper-function-name": "^7.23.0",
+ "@babel/helper-hoist-variables": "^7.22.5",
+ "@babel/helper-split-export-declaration": "^7.22.6",
+ "@babel/parser": "^7.23.0",
+ "@babel/types": "^7.23.0",
+ "debug": "^4.1.0",
+ "globals": "^11.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/traverse/node_modules/@babel/generator": {
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz",
+ "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.26.2",
+ "@babel/types": "^7.26.0",
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/traverse/node_modules/@babel/types": {
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz",
+ "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.25.9",
+ "@babel/helper-validator-identifier": "^7.25.9"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/traverse/node_modules/jsesc": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
+ "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/types": {
+ "version": "7.17.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz",
+ "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.16.7",
+ "to-fast-properties": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/jsesc": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -2660,6 +2916,24 @@
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
+ "node_modules/@types/prop-types": {
+ "version": "15.7.13",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
+ "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.12",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
+ "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/acorn": {
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.13.0.tgz",
@@ -3043,6 +3317,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/buffer-from": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-0.1.2.tgz",
@@ -3129,30 +3409,12 @@
"node": ">=12.5.0"
}
},
- "node_modules/color-name": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
- "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
- "license": "MIT",
- "optional": true
- },
- "node_modules/color-string": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
- "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "color-name": "^1.0.0",
- "simple-swizzle": "^0.2.2"
- }
- },
- "node_modules/color/node_modules/color-convert": {
+ "node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "devOptional": true,
"license": "MIT",
- "optional": true,
"dependencies": {
"color-name": "~1.1.4"
},
@@ -3160,12 +3422,23 @@
"node": ">=7.0.0"
}
},
- "node_modules/color/node_modules/color-name": {
+ "node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
- "optional": true
+ "optional": true,
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
},
"node_modules/combined-stream": {
"version": "1.0.8",
@@ -3442,6 +3715,15 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.45",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.45.tgz",
@@ -3727,6 +4009,19 @@
"eslint": ">=7.0.0"
}
},
+ "node_modules/eslint-import-resolver-alias": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-import-resolver-alias/-/eslint-import-resolver-alias-1.1.2.tgz",
+ "integrity": "sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ },
+ "peerDependencies": {
+ "eslint-plugin-import": ">=1.4.0"
+ }
+ },
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
@@ -4014,26 +4309,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/eslint/node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/eslint/node_modules/color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/eslint/node_modules/eslint-scope": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz",
@@ -4984,6 +5259,25 @@
"node": ">= 0.4"
}
},
+ "node_modules/javascript-natural-sort": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
+ "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jiti": {
+ "version": "1.21.6",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz",
+ "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5055,6 +5349,40 @@
"node": ">=6"
}
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+ "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^3.2.2",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -5071,6 +5399,27 @@
"node": ">=4.0"
}
},
+ "node_modules/jwa": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
+ "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
+ "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^1.4.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5137,6 +5486,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -5144,6 +5500,42 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -5151,6 +5543,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -5311,6 +5709,34 @@
}
}
},
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
@@ -5567,34 +5993,6 @@
"node": ">= 0.4"
}
},
- "node_modules/postcss": {
- "version": "8.4.31",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
- "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.6",
- "picocolors": "^1.0.0",
- "source-map-js": "^1.0.2"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6309,6 +6707,16 @@
"xtend": "~2.1.1"
}
},
+ "node_modules/to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
diff --git a/package.json b/package.json
index e25c769ee..4017d19fc 100644
--- a/package.json
+++ b/package.json
@@ -7,13 +7,16 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint ."
+ "lint": "eslint . --fix",
+ "test": "node ./src/utils/test.js"
},
"dependencies": {
"@emotion/cache": "^11.13.1",
"@emotion/react": "^11.13.3",
"@emotion/server": "^11.11.0",
+ "@tanstack/react-query": "^5.59.19",
"axios": "^1.7.7",
+ "jsonwebtoken": "^9.0.2",
"next": "^15.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -26,8 +29,12 @@
"@babel/preset-react": "^7.25.9",
"@emotion/babel-plugin": "^11.12.0",
"@eslint/js": "^9.13.0",
+ "@tanstack/react-query-devtools": "^5.59.19",
+ "@trivago/prettier-plugin-sort-imports": "^4.3.0",
+ "@types/react": "^18.3.12",
"eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0",
+ "eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.1",
"eslint-plugin-prettier": "^5.2.1",
diff --git a/pages/_app.js b/pages/_app.js
index d7800ab9e..5ff307bcf 100644
--- a/pages/_app.js
+++ b/pages/_app.js
@@ -1,19 +1,78 @@
-import { CacheProvider } from '@emotion/react';
+/** @jsxImportSource @emotion/react */
import createCache from '@emotion/cache';
-import GlobalContextProvider from '@/src/components/GlobalContextProvider';
-import GlobalLayout from '@/src/layouts/GlobalLayout';
+import { CacheProvider, css } from '@emotion/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+import dynamic from 'next/dynamic';
+import React from 'react';
+import Footer from '@components/Footer';
+import GNB from '@components/GNB';
+import Modal from '@components/Modal';
+import AuthProvider from '@contexts/AuthProvider';
+import ErrorProvider, { useError } from '@contexts/ErrorProvider';
+import PendingProvider, { useIsLoading } from '@contexts/PendingProvider';
import '@/styles/import.css';
const clientSideEmotionCache = createCache({ key: 'css' });
+const ViewportProviderWithNoSSR = dynamic(() => import('@contexts/ViewportProvider.jsx'), { ssr: false });
-export default function App({ Component, pageProps, emotionCache = clientSideEmotionCache }) {
+const style = {
+ message: css`
+ text-align: center;
+ font-size: 1.6rem;
+ font-weight: 500;
+ `,
+};
+
+function GlobalLayout({ children }) {
+ const isLoading = useIsLoading();
+ const err = useError();
+
+ return (
+ <>
+
+ {children}
+
+ {/* {isLoading && } */}
+ {isLoading && (
+
+ ๋ก๋ฉ ์ค์
๋๋ค.
+
+ )}
+ {err && (
+
+ {err.response?.data?.message || err.message}
+
+ )}
+ >
+ );
+}
+
+function GlobalContextProvider({ children, emotionCache = clientSideEmotionCache }) {
+ // NOTE SSR ํ๊ฒฝ์์ ํด๋ผ์ด์ธํธ๊ฐ ๋ฐ๋ก refetchํ์ง ์๋๋ก, staletime์ ์ธํ
ํ๋ค.
+ const [queryClient] = React.useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000 } } }));
return (
-
-
-
-
-
+
+
+
+
+ {children}
+
+
+
+
);
}
+
+export default function App({ Component, pageProps }) {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/pages/_document.js b/pages/_document.js
index 70e242c93..d92828338 100644
--- a/pages/_document.js
+++ b/pages/_document.js
@@ -1,20 +1,6 @@
-// import { Html, Head, Main, NextScript } from 'next/document';
-
-// export default function Document() {
-// return (
-//
-//
-//
-//
-//
-//
-// );
-// }
-
-import NextDocument, { Html, Head, Main, NextScript } from 'next/document';
-import createEmotionServer from '@emotion/server/create-instance';
import createCache from '@emotion/cache';
+import createEmotionServer from '@emotion/server/create-instance';
+import NextDocument, { Head, Html, Main, NextScript } from 'next/document';
import React from 'react';
export async function getInitialProps(ctx) {
@@ -22,10 +8,7 @@ export async function getInitialProps(ctx) {
const cache = createCache({ key: 'css' });
const { extractCriticalToChunks } = createEmotionServer(cache);
- ctx.renderPage = () =>
- originalRenderPage({
- enhanceApp: App => props => ,
- });
+ ctx.renderPage = () => originalRenderPage({ enhanceApp: App => props => });
const initialProps = await NextDocument.getInitialProps(ctx);
const emotionStyles = extractCriticalToChunks(initialProps.html);
diff --git a/pages/articles/[id].jsx b/pages/articles/[id].jsx
index 9ad8fe2cb..77bcb2f2b 100644
--- a/pages/articles/[id].jsx
+++ b/pages/articles/[id].jsx
@@ -1,16 +1,19 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import { useRouter } from 'next/router';
-import DropdownMenu from '@/src/components/DropdownMenu';
-import Input from '@/src/components/Input';
-import c from '@/src/utils/constants';
import Image from 'next/image';
-import { useEffect, useState } from 'react';
-import useAsync from '@/src/hooks/useAsync';
-import { getArticleById, getCommentsOfArticle, postCommentOfArticle } from '@/src/utils/api';
-import DropdownProvider, { useDropdown } from '../../src/contexts/DropdownContext';
-import Comment from '../../src/components/Comment';
import Link from 'next/link';
+import { useRouter } from 'next/router';
+import { useState } from 'react';
+import Comment from '@components/Comment';
+import DeleteModal from '@components/DeleteModal';
+import DropdownMenu from '@components/DropdownMenu';
+import Input from '@components/Input';
+import DropdownProvider, { useDropdown } from '@contexts/DropdownProvider';
+import useOwnMutation from '@hooks/useOwnMutation';
+import useOwnQuery from '@hooks/useOwnQuery';
+import { deleteArticle, getArticleById, getCommentsOfArticle, postCommentOfArticle } from '@utils/api';
+import c from '@utils/constants';
+import { toDateString } from '@utils/utils';
const style = {
articleDetailPage: css`
@@ -160,63 +163,60 @@ function ModifyButton() {
export default function ArticleDetail() {
const router = useRouter();
const { id } = router.query;
- const getArticleByIdAsync = useAsync(getArticleById);
- const getCommentsByIdAsync = useAsync(getCommentsOfArticle);
- const postCommentOfArticleAsync = useAsync(postCommentOfArticle);
const [article, setArticle] = useState({});
const [comments, setComments] = useState([]);
- const [date, setDate] = useState(new Date());
const [commentObj, setCommentObj] = useState({ ...c.EMPTY_INPUT_OBJ, name: 'comment', type: 'text' });
const [cursor, setCursor] = useState();
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+
+ const articleData = useOwnQuery({
+ queryFn: () => getArticleById(id),
+ queryKey: ['article', id],
+ onSuccess: result => setArticle(result),
+ enabled: !!id,
+ });
+ const commentsData = useOwnQuery({
+ queryFn: () => getCommentsOfArticle(id, { limit: 5 }),
+ queryKey: ['comments', id],
+ onSuccess: result => {
+ setComments(result.list);
+ setCursor(result.nextCursor);
+ },
+ enabled: !!id,
+ });
+ const postCommentMutation = useOwnMutation({
+ mutationFn: data => postCommentOfArticle(id, data),
+ invalidQueryKey: ['comments', id],
+ });
+ const deleteArticleMutation = useOwnMutation({
+ mutationFn: _ => deleteArticle(id),
+ onSuccess: () => router.push('/articles'),
+ });
const handleDropdownClick = modify => {
switch (modify) {
case c.MODIFY.EDIT:
- router.push('/articles/post');
+ router.push(`/articles/post/${id}`);
break;
case c.MODIFY.DELETE:
+ setDeleteModalOpen(true);
}
};
- const handleCommentChange = value => {
- value
- ? setCommentObj(old => {
- return { ...old, value };
- })
- : null;
- };
const handleSubmitComment = async () => {
const data = { content: commentObj.value, ownerId: '186dc25d-3079-47d4-a7ed-3dd6e4e7f146' };
- const result = await postCommentOfArticleAsync(id, data);
-
- if (!result) return null;
-
- router.reload();
+ postCommentMutation.mutate(data);
+ };
+ const handleDeleteArticle = async () => {
+ deleteArticleMutation.mutate();
};
-
- useEffect(() => {
- async function handleLoadArticle() {
- const data = await getArticleByIdAsync(id);
- if (!data) return null;
-
- setArticle(data);
- setDate(new Date(data.createdAt));
- }
- async function handleLoadComments() {
- // const nextCursor = cursor ? { cursor } : {};
- const data = await getCommentsByIdAsync(id, { limit: 5 });
- if (!data) return null;
-
- setComments(data.list);
- setCursor(data.nextCursor);
- }
- if (id) {
- handleLoadArticle();
- handleLoadComments();
- }
- }, [id]);
return (
+
setDeleteModalOpen(false)}
+ />
@@ -235,7 +235,7 @@ export default function ArticleDetail() {
{article.owner?.nickname}
- {`${date.getFullYear()}. ${date.getMonth()}. ${date.getDate()}`}
+ {toDateString(article?.createdAt)}
@@ -258,11 +258,11 @@ export default function ArticleDetail() {
inputObj={commentObj}
label={'๋๊ธ๋ฌ๊ธฐ'}
placeholder={'๋๊ธ์ ์
๋ ฅํด์ฃผ์ธ์.'}
- onChange={handleCommentChange}
+ onChange={setCommentObj}
textarea
comment
/>
-
diff --git a/pages/articles/index.jsx b/pages/articles/index.jsx
index 7ad9a2c43..3fadc12fc 100644
--- a/pages/articles/index.jsx
+++ b/pages/articles/index.jsx
@@ -1,8 +1,8 @@
/** @jsxImportSource @emotion/react */
-import BestArticles from '@/src/components/article/BestArticles';
-import Articles from '@/src/components/article/Articles';
import { css } from '@emotion/react';
-import DropdownProvider from '@/src/contexts/DropdownContext';
+import Articles from '@components/article/Articles';
+import BestArticles from '@components/article/BestArticles';
+import DropdownProvider from '@contexts/DropdownProvider';
const style = {
freeBoard: css`
diff --git a/pages/articles/post/[articleId].jsx b/pages/articles/post/[articleId].jsx
index 4cf54221d..238d45e99 100644
--- a/pages/articles/post/[articleId].jsx
+++ b/pages/articles/post/[articleId].jsx
@@ -1,5 +1,5 @@
-import ArticlePost from '@/src/components/article/ArticlePost';
import { useRouter } from 'next/router';
+import ArticlePost from '@components/article/ArticlePost';
export default function ArticlePatchPage() {
const router = useRouter();
diff --git a/pages/articles/post/index.jsx b/pages/articles/post/index.jsx
index 2bdbda067..de79d2f0f 100644
--- a/pages/articles/post/index.jsx
+++ b/pages/articles/post/index.jsx
@@ -1,4 +1,4 @@
-import ArticlePost from '@/src/components/article/ArticlePost';
+import ArticlePost from '@components/article/ArticlePost';
export default function ArticlePostPage() {
return
;
diff --git a/pages/auth/signIn.jsx b/pages/auth/signIn.jsx
new file mode 100644
index 000000000..63117304f
--- /dev/null
+++ b/pages/auth/signIn.jsx
@@ -0,0 +1,99 @@
+/** @jsxImportSource @emotion/react */
+import { css } from '@emotion/react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import { useEffect, useState } from 'react';
+import SignInput from '@components/SignInput';
+import { useAuth } from '@contexts/AuthProvider';
+import SignLayout from '@layouts/SignLayout';
+
+const style = {
+ login: css``,
+};
+
+export default function SignInPage() {
+ const auth = useAuth();
+ const router = useRouter();
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [errorMsg, setErrorMsg] = useState({});
+
+ const handleSubmit = async () => {
+ if (Object.keys(errorMsg).length === 0) {
+ setErrorMsg({ email: '์ด๋ฉ์ผ์ ํ์ธํด ์ฃผ์ธ์.', password: '๋น๋ฐ๋ฒํธ๋ฅผ ํ์ธํด ์ฃผ์ธ์.' });
+ return null;
+ }
+
+ setErrorMsg({});
+ const user = await auth.login({ email, password });
+ if (user) router.push('/items');
+ };
+ const handleKeyDown = e => {
+ // NOTE Enter Key
+ if (e.keyCode === 13) handleSubmit();
+ };
+
+ useEffect(() => {
+ const token = localStorage.getItem('accessToken');
+ if (token) router.push('/items');
+ }, []);
+
+ return (
+
+
+
+
+
+ ๊ฐํธ ๋ก๊ทธ์ธํ๊ธฐ
+
+
+
+
+
+
+
+
+
+
+
+
+ ํ๋ค๋ง์ผ์ด ์ฒ์์ด์ ๊ฐ์? ํ์๊ฐ์
+
+
+ {/*
+
+
๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์ต๋๋ค.
+
ํ์ธ
+
+
*/}
+
+
+ );
+}
diff --git a/pages/auth/signUp.jsx b/pages/auth/signUp.jsx
new file mode 100644
index 000000000..8dc9e1e41
--- /dev/null
+++ b/pages/auth/signUp.jsx
@@ -0,0 +1,137 @@
+/** @jsxImportSource @emotion/react */
+import { css } from '@emotion/react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import { useEffect, useState } from 'react';
+import SignInput from '@components/SignInput';
+import { useAuth } from '@contexts/AuthProvider';
+import useOwnMutation from '@hooks/useOwnMutation';
+import SignLayout from '@layouts/SignLayout';
+import { signUp } from '@/src/utils/api';
+
+const style = {
+ signUp: css`
+ padding-bottom: 7rem;
+ `,
+};
+
+const mockData = {
+ email: 'TESTKTY13@email.com',
+ nickname: 'TESTKTY13',
+ password: 'password',
+ passwordConfirmation: 'password',
+};
+
+export default function SignupPage() {
+ const router = useRouter();
+ const { login } = useAuth();
+ const [email, setEmail] = useState('');
+ const [nickname, setNickname] = useState('');
+ const [password, setPassword] = useState('');
+ const [passwordConfirmation, setPasswordConfirmation] = useState('');
+ const [errorMsg, setErrorMsg] = useState({});
+ const signUpMutation = useOwnMutation({
+ mutationFn: data => signUp(data),
+ onSuccess: (_, variables) => {
+ login({ email: variables.email, password: variables.password });
+ router.push('/items');
+ },
+ });
+
+ const handleSubmit = async () => {
+ if (password !== passwordConfirmation) {
+ setErrorMsg({ passwordConfirmation: '๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์์.' });
+ return null;
+ }
+ setErrorMsg({});
+
+ const data = { email, nickname, password };
+
+ signUpMutation.mutate(data);
+ };
+ const handleKeyDown = e => {
+ // NOTE Enter Key
+ if (e.keyCode === 13) handleSubmit();
+ };
+
+ useEffect(() => {
+ const token = localStorage.getItem('accessToken');
+ if (token) router.push('/items');
+ }, []);
+
+ return (
+
+
+
+
+
+ ๊ฐํธ ๋ก๊ทธ์ธํ๊ธฐ
+
+
+
+
+
+
+
+
+
+
+
+
+ ์ด๋ฏธ ํ์์ด์ ๊ฐ์? ๋ก๊ทธ์ธ
+
+
+ {/*
+
+
์ฌ์ฉ ์ค์ธ ์ด๋ฉ์ผ์
๋๋ค.
+
ํ์ธ
+
+
*/}
+
+
+ );
+}
diff --git a/pages/index.jsx b/pages/index.jsx
index fadd9b2c3..1e42e1c0b 100644
--- a/pages/index.jsx
+++ b/pages/index.jsx
@@ -1,9 +1,9 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import { useViewport } from '@/src/contexts/ViewportContext.jsx';
-import c from '@/src/utils/constants.js';
-import Link from 'next/link';
import Image from 'next/image';
+import Link from 'next/link';
+import { useViewport } from '@contexts/ViewportProvider';
+import c from '@utils/constants';
const style = {
h1: css`
@@ -164,7 +164,7 @@ export default function LandingPage() {
-
+
Hot item
@@ -183,7 +183,7 @@ export default function LandingPage() {
-
+
Search
@@ -202,7 +202,7 @@ export default function LandingPage() {
-
+
Register
diff --git a/pages/items/[id].jsx b/pages/items/[id].jsx
index bc6e42a46..b85b1c2af 100644
--- a/pages/items/[id].jsx
+++ b/pages/items/[id].jsx
@@ -1,3 +1,355 @@
-function ItemsDetailPage() {}
+/** @jsxImportSource @emotion/react */
+import { css } from '@emotion/react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import { useState } from 'react';
+import Comment from '@components/Comment';
+import DeleteModal from '@components/DeleteModal';
+import DropdownMenu from '@components/DropdownMenu';
+import Input from '@components/Input';
+import TagButton from '@components/product/TagButton';
+import { useAuth } from '@contexts/AuthProvider';
+import DropdownProvider, { useDropdown } from '@contexts/DropdownProvider';
+import useOwnMutation from '@hooks/useOwnMutation';
+import useOwnQuery from '@hooks/useOwnQuery';
+import {
+ deleteProduct,
+ deleteProductFavorite,
+ getCommentsOfProduct,
+ getProductDetail,
+ postCommentOfProduct,
+ postProductFavorite,
+} from '@utils/api';
+import c from '@utils/constants';
+import { toDateString, toPriceString } from '@utils/utils';
-export default ItemsDetailPage;
+const style = {
+ itemDetailPost: css`
+ margin-top: 2.9rem;
+ display: flex;
+ gap: 2.4rem;
+ padding-bottom: 4rem;
+ border-bottom: 1px solid var(--gray-200);
+
+ #itemDetail {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2.4rem;
+
+ #itemDetailContent {
+ flex-grow: 1;
+ }
+
+ #itemDetailInfo {
+ margin-top: ${6.2 - 2.4}rem;
+ }
+ }
+ `,
+ itemDetailTitle: css`
+ height: 11.2rem;
+ border-bottom: 1px solid var(--gray-200);
+
+ .title-and-button {
+ display: flex;
+ justify-content: space-between;
+
+ > h1 {
+ font-size: 2.4rem;
+ line-height: 3.2rem;
+ font-weight: 600;
+ color: var(--gray-800);
+ }
+ }
+
+ .price {
+ margin-top: 1.6rem;
+
+ font-size: 4rem;
+ line-height: 4.773rem;
+ color: var(--gray-800);
+ font-weight: 600;
+ }
+ `,
+ itemDetailContent: css`
+ h2 {
+ font-size: 1.6rem;
+ line-height: 2.6rem;
+ font-weight: 600;
+ color: var(--gray-600);
+ }
+
+ pre {
+ margin-top: 1.6rem;
+
+ font-size: 1.6rem;
+ line-height: 2.6rem;
+ font-weight: 400;
+ color: var(--gray-600);
+ }
+ `,
+ itemDetailTag: css`
+ p {
+ font-size: 1.6rem;
+ line-height: 2.6rem;
+ font-weight: 600;
+ color: var(--gray-600);
+ }
+
+ .tags {
+ margin-top: 1.6rem;
+
+ display: flex;
+ gap: 0.8rem;
+ }
+ `,
+ itemDetailInfo: css`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1.6rem;
+
+ .owner-info {
+ flex-grow: 1;
+
+ display: inline-block;
+ margin-left: 0.8rem;
+
+ > span {
+ display: block;
+ font-size: 1.2rem;
+ line-height: 1.8rem;
+ font-weight: 400;
+
+ &.nickname {
+ color: var(--gray-600);
+ }
+
+ &.time {
+ margin-top: 0.4rem;
+ color: var(--gray-400);
+ }
+ }
+ }
+
+ button {
+ padding: 0.4rem 1.2rem;
+ border: 1px solid var(--gray-200);
+ border-radius: 35px;
+ display: flex;
+ align-items: center;
+
+ span {
+ margin-left: 0.5rem;
+ }
+ }
+ `,
+ comments: css`
+ margin-top: 4rem;
+
+ #commentsForm {
+ button {
+ display: block;
+ margin-left: auto;
+
+ padding: 1.2rem 2.3rem;
+ border-radius: 8px;
+
+ color: var(--gray-100);
+ font-size: 1.6rem;
+ line-height: 2.6rem;
+ font-weight: 600;
+ }
+ }
+
+ #commentsList {
+ margin-top: 4rem;
+ display: flex;
+ flex-direction: column;
+ gap: 2.4rem;
+
+ #noComment {
+ margin: 0 auto;
+ }
+ }
+ `,
+ returnButton: css`
+ margin-top: 6.4rem;
+ margin-bottom: 17.3rem;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ button {
+ padding: 1.2rem 6.4rem;
+ border-radius: 40px;
+
+ font-size: 1.8rem;
+ line-height: 2.6rem;
+ font-weight: 600;
+
+ img {
+ margin-left: 0.8rem;
+ }
+ }
+ `,
+};
+
+function ModifyButton() {
+ const { dropdownOpen, setDropdownOpen } = useDropdown();
+
+ const toggleDropdown = () => setDropdownOpen(!dropdownOpen);
+
+ return (
+
+
+
+ );
+}
+
+export default function ItemDetailPage() {
+ const router = useRouter();
+ const { id } = router.query;
+ const { user, tokenExpireCheck } = useAuth(true);
+ const [item, setItem] = useState({});
+ const [comments, setComments] = useState([]);
+ const [commentObj, setCommentObj] = useState({ ...c.EMPTY_INPUT_OBJ, name: 'comment', type: 'text' });
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+
+ const getProductDetailQuery = useOwnQuery({
+ queryFn: _ => getProductDetail(id),
+ queryKey: ['product', id],
+ onSuccess: result => setItem(result),
+ });
+ const getCommentsOfProductQuery = useOwnQuery({
+ queryFn: _ => getCommentsOfProduct(id, { limit: 5 }),
+ queryKey: ['comments', id],
+ onSuccess: result => setComments(result.list),
+ });
+ const postCommentOfProductMutation = useOwnMutation({
+ mutationFn: data => postCommentOfProduct(id, data),
+ invalidQueryKey: ['comments', id],
+ });
+ const deleteProductMutation = useOwnMutation({ mutationFn: _ => deleteProduct(id), onSuccess: () => router.push('/items') });
+ const postProductFavoritetMutation = useOwnMutation({
+ mutationFn: _ => postProductFavorite(id),
+ invalidQueryKey: ['product', id],
+ });
+ const deleteProductFavoriteMutation = useOwnMutation({
+ mutationFn: _ => deleteProductFavorite(id),
+ invalidQueryKey: ['product', id],
+ });
+
+ const handleDropdownClick = modify => {
+ switch (modify) {
+ case c.MODIFY.EDIT:
+ router.push(`/items/registration/${id}`);
+ break;
+ case c.MODIFY.DELETE:
+ setDeleteModalOpen(true);
+ }
+ };
+ const handleDeleteItem = async () => {
+ deleteProductMutation.mutate();
+ };
+ const handleFavorite = async () => {
+ if (item.isFavorite) deleteProductFavoriteMutation.mutate();
+ else postProductFavoritetMutation.mutate();
+ };
+ const handlePostComments = async () => {
+ if (!tokenExpireCheck()) router.push('/items');
+ postCommentOfProductMutation.mutate({ content: commentObj.value.trim() });
+ };
+
+ return (
+
+
setDeleteModalOpen(false)} />
+
+
+ 0 ? item.images[0] : '/Image/img_product_default.png'}
+ alt="default image"
+ width={486}
+ height={486}
+ priority
+ />
+
+
+
+
+
{item?.name}
+
+ }
+ list={c.MODIFY}
+ dictionary={c.MODIFY_MSG}
+ onClick={handleDropdownClick}
+ />
+
+
+
{toPriceString(item?.price)}์
+
+
+
์ํ ์๊ฐ
+
{item?.description}
+
+
+
์ํ ํ๊ทธ
+
+ {item?.tags?.map(tag => (
+
+ ))}
+
+
+
+
+
+ {item?.ownerNickname}
+ {toDateString(item?.createdAt)}
+
+
+
+ {item?.likeCount}
+
+
+
+
+
+
+
+
+ ๋ชฉ๋ก์ผ๋ก ๋์๊ฐ๊ธฐ
+
+
+
+
+
+ );
+}
diff --git a/pages/items/index.jsx b/pages/items/index.jsx
index 38143341b..4bbedf9e7 100644
--- a/pages/items/index.jsx
+++ b/pages/items/index.jsx
@@ -1,9 +1,8 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import BestProducts from '@/src/components/product/BestProducts';
-import ProductsOnSale from '@/src/components/product/ProductsOnSale.jsx';
-import c from '@/src/utils/constants.js';
-import DropdownProvider from '@/src/contexts/DropdownContext';
+import ProductsOnSale from '@components/product/ProductsOnSale';
+import DropdownProvider from '@contexts/DropdownProvider';
+import c from '@utils/constants';
const style = {
itemsPage: css`
@@ -33,8 +32,8 @@ export default function ItemsPage() {
return (
*/}
diff --git a/pages/items/registration/[productId].jsx b/pages/items/registration/[productId].jsx
new file mode 100644
index 000000000..2002b52e8
--- /dev/null
+++ b/pages/items/registration/[productId].jsx
@@ -0,0 +1,8 @@
+import { useRouter } from 'next/router';
+import ItemRegistration from '@components/product/ItemRegistration';
+
+export default function ItemEditPage() {
+ const router = useRouter();
+ const { productId } = router.query;
+ return
;
+}
diff --git a/pages/items/registration/index.jsx b/pages/items/registration/index.jsx
new file mode 100644
index 000000000..258a97586
--- /dev/null
+++ b/pages/items/registration/index.jsx
@@ -0,0 +1,5 @@
+import ItemRegistration from '@components/product/ItemRegistration';
+
+export default function ItemRegistrationPage() {
+ return
;
+}
diff --git a/prettier.config.mjs b/prettier.config.mjs
new file mode 100644
index 000000000..b8c79ce57
--- /dev/null
+++ b/prettier.config.mjs
@@ -0,0 +1,30 @@
+export default {
+ singleQuote: true,
+ semi: true,
+ useTabs: false,
+ tabWidth: 2,
+ trailingComma: 'all',
+ printWidth: 130,
+ arrowParens: 'avoid',
+ bracketSpacing: true,
+ endOfLine: 'auto',
+
+ // import ์ ๋ ฌ ์ค์
+ plugins: ['@trivago/prettier-plugin-sort-imports'],
+ importOrder: [
+ '^@pages/(.*)$',
+ '^@components/(.*)$',
+ '^@contexts/(.*)$',
+ '^@hooks/(.*)$',
+ '^@layouts/(.*)$',
+ '^@utils/(.*)$',
+ '^@styles/(.*)$',
+ '^@/(.*)$',
+ '^.*\\.(svg|jpg|jpeg|png)$',
+ '^.*\\.css$',
+ '^[./]',
+ ],
+ importOrderSeparation: false, // import ๊ทธ๋ฃน ์ฌ์ด ๋น ์ค ์์
+ importOrderSortSpecifiers: true, // import ๊ตฌ๋ฌธ ๋ด ์ฌ์์ ์ ๋ ฌ
+ importOrderCaseInsensitive: false, // ๋์๋ฌธ์ ๊ตฌ๋ถ
+};
diff --git a/public/Image/Img_inquiry_empty.png b/public/Image/Img_inquiry_empty.png
new file mode 100644
index 000000000..e1b617f99
Binary files /dev/null and b/public/Image/Img_inquiry_empty.png differ
diff --git a/public/Image/img_product_default.png b/public/Image/img_product_default.png
new file mode 100644
index 000000000..89e3899bd
Binary files /dev/null and b/public/Image/img_product_default.png differ
diff --git a/src/components/Comment.jsx b/src/components/Comment.jsx
index 3e13eeda5..cabd64570 100644
--- a/src/components/Comment.jsx
+++ b/src/components/Comment.jsx
@@ -1,14 +1,15 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import DropdownMenu from './DropdownMenu';
-import c from '@/src/utils/constants';
-import { useDropdownItem } from '../contexts/DropdownContext';
-import { useState } from 'react';
import { useRouter } from 'next/router';
-import useAsync from '@/src/hooks/useAsync';
-import { patchComment, deleteComment } from '@/src/utils/api';
-import Input from '@/src/components/Input';
-import Modal from '@/src/components/Modal';
+import { useState } from 'react';
+import DropdownMenu from '@components/DropdownMenu';
+import Input from '@components/Input';
+import { useDropdownItem } from '@contexts/DropdownProvider';
+import useOwnMutation from '@hooks/useOwnMutation';
+import { deleteComment, patchComment } from '@utils/api';
+import c from '@utils/constants';
+import { toDateString } from '@utils/utils';
+import DeleteModal from './DeleteModal';
const style = {
comment: css`
@@ -69,38 +70,34 @@ const style = {
`,
};
-export default function Comment({ item, ModifyButton }) {
- const createdDate = new Date(item.createdAt);
+export default function Comment({ item, parentId, ModifyButton }) {
+ const nickname = item.owner ? item.owner.nickname : item.writer.nickname;
const router = useRouter();
- const patchCommentAsync = useAsync(patchComment);
- const deleteCommentAsync = useAsync(deleteComment);
const { item: modify, setItem: setModify } = useDropdownItem();
const [commentObj, setCommentObj] = useState({ ...c.EMPTY_INPUT_OBJ, name: 'comment', type: 'text', value: item.content });
- const handleCommentChange = value => {
- value
- ? setCommentObj(old => {
- return { ...old, value };
- })
- : null;
- };
- const handleSubmitComment = async () => {
- const data = { content: commentObj.value };
- const result = await patchCommentAsync(item.id, data);
- if (!result) return null;
+ const patchCommentMutation = useOwnMutation({
+ mutationFn: data => patchComment(item.id, data),
+ invalidQueryKey: ['comments', parentId],
+ onSuccess: _ => setModify(null),
+ });
+ const deleteCommentMutation = useOwnMutation({
+ mutationFn: _ => deleteComment(item.id),
+ invalidQueryKey: ['comments', parentId],
+ onSuccess: _ => setModify(null),
+ });
- router.reload();
+ const handleSubmitComment = async () => {
+ const data = { content: commentObj.value?.trim?.() };
+ patchCommentMutation.mutate(data);
};
const handleDeleteComment = async () => {
- const result = await deleteCommentAsync(item.id);
- if (!result) return null;
-
- router.reload();
+ deleteCommentMutation.mutate();
};
return (
- {!modify && (
+ {modify !== c.MODIFY.EDIT && (
{item.content}
@@ -108,28 +105,23 @@ export default function Comment({ item, ModifyButton }) {
)}
{modify === c.MODIFY.EDIT && (
)}
- {modify === c.MODIFY.DELETE && (
-
router.reload() },
- ]}
- >
- asdf
-
- )}
+
setModify(null)}
+ />
- {item.owner?.nickname}
- {`${createdDate.getFullYear()}. ${createdDate.getMonth()}. ${createdDate.getDate()}`}
+ {nickname}
+ {toDateString(item?.createdAt)}
diff --git a/src/components/DeleteModal.jsx b/src/components/DeleteModal.jsx
new file mode 100644
index 000000000..0b39e1941
--- /dev/null
+++ b/src/components/DeleteModal.jsx
@@ -0,0 +1,18 @@
+import Modal from '@components/Modal';
+
+export default function DeleteModal({ isOpen, onConfirmClick, onCancelClick }) {
+ return (
+ <>
+ {isOpen && (
+
+ ์ ๋ง ์ญ์ ํ์๊ฒ ์ต๋๊น?
+
+ )}
+ >
+ );
+}
diff --git a/src/components/DropdownMenu.jsx b/src/components/DropdownMenu.jsx
index 475b1ba76..1de21c31c 100644
--- a/src/components/DropdownMenu.jsx
+++ b/src/components/DropdownMenu.jsx
@@ -1,8 +1,8 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import c from '@/src/utils/constants';
import { useEffect, useRef } from 'react';
-import { useDropdown } from '../contexts/DropdownContext';
+import { useDropdown } from '@contexts/DropdownProvider';
+import c from '@utils/constants';
const style = {
dropdownMenu: css`
@@ -77,13 +77,11 @@ export default function DropdownMenu({ DropdownButton, list, dictionary, onClick
{DropdownButton}
{dropdownOpen && (
- {Object.values(list).map(item => {
- return (
- - handleClick(item)} key={item}>
- {dictionary[item]}
-
- );
- })}
+ {Object.values(list).map(item => (
+ - handleClick(item)} key={item}>
+ {dictionary[item]}
+
+ ))}
)}
diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx
index d00d101f5..875c085e9 100644
--- a/src/components/Footer.jsx
+++ b/src/components/Footer.jsx
@@ -1,8 +1,8 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import c from '../utils/constants.js';
-import Link from 'next/link';
import Image from 'next/image';
+import Link from 'next/link';
+import c from '@utils/constants';
const style = {
footer: css`
diff --git a/src/components/GNB.jsx b/src/components/GNB.jsx
new file mode 100644
index 000000000..08aef1f6c
--- /dev/null
+++ b/src/components/GNB.jsx
@@ -0,0 +1,89 @@
+/** @jsxImportSource @emotion/react */
+import { css } from '@emotion/react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import { useAuth } from '@contexts/AuthProvider';
+import { useViewport } from '@contexts/ViewportProvider';
+import c from '@utils/constants';
+
+const style = {
+ header: css`
+ padding: 0 20rem;
+ height: 7rem;
+ border-bottom: 1px solid #dfdfdf;
+
+ @media (max-width: ${c.BREAKPOINTS.TABLET}px) {
+ padding-left: 2.4rem;
+ padding-right: 2.4rem;
+ }
+
+ @media (max-width: ${c.BREAKPOINTS.MOBILE}px) {
+ padding-left: 1.6rem;
+ padding-right: 1.6rem;
+ }
+ `,
+ topNav: css`
+ flex: 1;
+ margin-left: 3.2rem;
+ display: flex;
+ gap: 3.2rem;
+ color: var(--gray-600);
+ font-weight: 700;
+ font-size: 1.8rem;
+ line-height: 2.148rem;
+ text-align: center;
+
+ @media (max-width: ${c.BREAKPOINTS.MOBILE}px) {
+ margin-left: 1.6rem;
+ gap: 0.8rem;
+ font-size: 1.6rem;
+ line-height: 1.909rem;
+ }
+ `,
+ loginButton: css`
+ font-weight: 600;
+ border-radius: 8px;
+ padding: 1.1rem 2.3rem;
+ line-height: 2.6rem;
+ `,
+};
+
+export default function GNB() {
+ const viewport = useViewport();
+ const router = useRouter();
+ const { logout } = useAuth();
+ // NOTE url path์ ์ฒซ ๋ถ๋ถ์ ๋ฐ์์์ Nav ๋ฐ ์์ ๋ณ๊ฒฝํ๊ธฐ ์ํจ.
+ const firstPath = router.asPath.split('/')[1] ?? '';
+
+ const handleLoginClick = () => {
+ if (!localStorage.getItem('accessToken')) return router.push('/auth/signIn');
+
+ logout();
+ router.reload();
+ };
+
+ return (
+
+
+
+
+
+
+ {!localStorage.getItem('accessToken') ? '๋ก๊ทธ์ธ' : '๋ก๊ทธ์์'}
+
+
+ );
+}
diff --git a/src/components/Input.jsx b/src/components/Input.jsx
index 4ae501808..abcacf611 100644
--- a/src/components/Input.jsx
+++ b/src/components/Input.jsx
@@ -34,14 +34,12 @@ export default function Input({ inputObj, label, placeholder, onChange, onBlur,
const handleChange = e => {
setValue(e.target.value);
- onChange ? onChange(e.target.value) : null;
- };
- const handleBlur = () => {
- onBlur({ value, name, type, errMsg });
+ onChange ? onChange({ value: e.target.value, name, type, errMsg }) : null;
};
+ const handleBlur = () => onBlur({ value, name, type, errMsg });
+
const handleKeyDown = e => {
- onKeyDown(e, { value, name, type, errMsg });
- setValue('');
+ onKeyDown(e, { value, name, type, errMsg }, setValue);
};
useEffect(() => {
diff --git a/src/components/Modal.jsx b/src/components/Modal.jsx
index bb7be3cea..4f65c11d4 100644
--- a/src/components/Modal.jsx
+++ b/src/components/Modal.jsx
@@ -70,7 +70,7 @@ export default function Modal({ children, buttons = [] }) {
setModalOff('off');
button.onClick();
}}
- key={button}
+ key={button.Msg}
>
{button.Msg}
diff --git a/src/components/PaginationBar.jsx b/src/components/PaginationBar.jsx
index 6bc76f73a..cef7e9858 100644
--- a/src/components/PaginationBar.jsx
+++ b/src/components/PaginationBar.jsx
@@ -1,14 +1,13 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import { useViewport } from '../contexts/ViewportContext.jsx';
-import usePagination from '../hooks/usePagination.js';
-import c from '../utils/constants.js';
+import { useViewport } from '@contexts/ViewportProvider';
+import usePagination from '@hooks/usePagination';
const style = {
paginationBar: css`
height: 4rem;
- .pagination.productOnSale {
+ .pagination {
margin: 0 auto;
width: 30.4rem;
height: 100%;
@@ -44,25 +43,28 @@ const style = {
export default function PaginationBar({ totalCount, pageSize, onPageChange }) {
const viewport = useViewport();
- const { currentPage, bundle, bundleCount, totalBundleCounts, goToPage, nextBundle, prevBundle } = usePagination(
+ const { currentPage, bundlePages, goToPage, getNextBundle, getPrevBundle, canGoPrev, canGoNext } = usePagination(
totalCount,
pageSize,
- c.BUNDLE_SIZE,
- onPageChange,
);
+ const handlePageChange = page => {
+ goToPage(page);
+ onPageChange?.(page);
+ };
+
return (
);
diff --git a/src/components/article/Articles.jsx b/src/components/article/Articles.jsx
index 6c74f234c..2633b8560 100644
--- a/src/components/article/Articles.jsx
+++ b/src/components/article/Articles.jsx
@@ -1,15 +1,15 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import ArticlesTitle from './ArticlesTitle';
-import c from '../../utils/constants';
-import { useCallback, useEffect, useState } from 'react';
-import { getArticles } from '@/src/utils/api';
-import useAsync from '@/src/hooks/useAsync';
-import { useViewport } from '@/src/contexts/ViewportContext';
-import PaginationBar from '../PaginationBar';
-import Article from './Article';
import Link from 'next/link';
-import { useDropdownItem } from '../../contexts/DropdownContext';
+import { useState } from 'react';
+import PaginationBar from '@components/PaginationBar';
+import Article from '@components/article/Article';
+import ArticlesTitle from '@components/article/ArticlesTitle';
+import { useDropdownItem } from '@contexts/DropdownProvider';
+import { useViewport } from '@contexts/ViewportProvider';
+import useOwnQuery from '@hooks/useOwnQuery';
+import { getArticles } from '@utils/api';
+import c from '@utils/constants';
const style = {
articles: css`
@@ -28,31 +28,28 @@ const style = {
export default function Articles() {
const viewport = useViewport();
const { item: sortOrder = c.SORT_ORDER.RECENT } = useDropdownItem();
- const getArticlesAsync = useAsync(getArticles);
const [now, setNow] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const [articles, setArticles] = useState([]);
const [totalCount, setTotalCount] = useState(0);
- const handleSearch = query => setSearchQuery(query);
- const handlePageChange = useCallback(p => setNow(p), []);
-
- useEffect(() => {
- async function handleLoadArticles() {
- const data = await getArticlesAsync({
+ const data = useOwnQuery({
+ queryFn: () =>
+ getArticles({
page: now,
pageSize: c.ARTICLE_PAGE_SIZE[viewport],
orderBy: sortOrder,
keyword: searchQuery,
- });
- if (!data) return null;
+ }),
+ queryKey: ['articles', now, sortOrder, searchQuery, viewport],
+ onSuccess: result => {
+ setArticles(result.list);
+ setTotalCount(result.totalCount);
+ },
+ });
- setArticles(data.list);
- setTotalCount(data.totalCount);
- setNow(now);
- }
- handleLoadArticles();
- }, [viewport, now, sortOrder, searchQuery, getArticlesAsync]);
+ const handleSearch = query => setSearchQuery(query);
+ const handlePageChange = p => setNow(p);
return (
@@ -60,13 +57,11 @@ export default function Articles() {
- {articles?.map(article => {
- return (
-
-
-
- );
- })}
+ {articles?.map(article => (
+
+
+
+ ))}
diff --git a/src/components/article/ArticlesTitle.jsx b/src/components/article/ArticlesTitle.jsx
index 53624e069..ed88bd9ae 100644
--- a/src/components/article/ArticlesTitle.jsx
+++ b/src/components/article/ArticlesTitle.jsx
@@ -1,8 +1,8 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import SortOrderSelect from '../SortOrderSelect';
-import SearchBar from '../SearchBar';
import Link from 'next/link';
+import SearchBar from '@components/SearchBar';
+import SortOrderSelect from '@components/SortOrderSelect';
const style = {
titleAndButton: css`
diff --git a/src/components/article/BestArticles.jsx b/src/components/article/BestArticles.jsx
index ac8c7cd21..9f87e8c40 100644
--- a/src/components/article/BestArticles.jsx
+++ b/src/components/article/BestArticles.jsx
@@ -1,13 +1,12 @@
/** @jsxImportSource @emotion/react */
-import { useViewport } from '@/src/contexts/ViewportContext';
-import useAsync from '@/src/hooks/useAsync';
-import { getArticles } from '@/src/utils/api';
-import c from '@/src/utils/constants';
import { css } from '@emotion/react';
-import Image from 'next/image';
-import { useEffect, useState } from 'react';
-import Article from './Article';
import Link from 'next/link';
+import { useState } from 'react';
+import Article from '@components/article/Article';
+import { useViewport } from '@contexts/ViewportProvider';
+import useOwnQuery from '@hooks/useOwnQuery';
+import { getArticles } from '@utils/api';
+import c from '@utils/constants';
const style = {
BestArticles: css`
@@ -29,33 +28,27 @@ const style = {
export default function BestArticles() {
const viewport = useViewport();
- const getArticlesAsync = useAsync(getArticles);
const [articles, setArticles] = useState([]);
- useEffect(() => {
- async function handleLoadArticles() {
- const data = await getArticlesAsync({
+ const getBestArticlesQuery = useOwnQuery({
+ queryFn: _ =>
+ getArticles({
page: 1,
pageSize: c.BEST_ARTICLE_PAGE_SIZE[viewport],
- });
- if (!data) return null;
-
- setArticles(data.list);
- }
- handleLoadArticles();
- }, [viewport, getArticlesAsync]);
+ }),
+ queryKey: ['bestArticles', viewport],
+ onSuccess: result => setArticles(result.list),
+ });
return (
๋ฒ ์คํธ ๊ฒ์๊ธ
- {articles?.map(article => {
- return (
-
-
-
- );
- })}
+ {articles?.map(article => (
+
+
+
+ ))}
);
diff --git a/src/components/product/BestProducts.jsx b/src/components/product/BestProducts.jsx
index e7b30000a..d0c035863 100644
--- a/src/components/product/BestProducts.jsx
+++ b/src/components/product/BestProducts.jsx
@@ -1,11 +1,11 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import { useEffect, useState } from 'react';
-import { getProducts } from '@/src/utils/api.js';
-import useAsync from '@/src/hooks/useAsync.js';
-import { useViewport } from '@/src/contexts/ViewportContext.jsx';
-import ProductCard from './ProductCard.jsx';
-import c from '@/src/utils/constants.js';
+import { useState } from 'react';
+import ProductCard from '@components/product/ProductCard';
+import { useViewport } from '@contexts/ViewportProvider';
+import useOwnQuery from '@hooks/useOwnQuery';
+import { getProducts } from '@utils/api';
+import c from '@utils/constants';
const style = {
bestProductsTitle: css`
@@ -43,22 +43,11 @@ const style = {
export default function BestProducts() {
const viewport = useViewport();
const [items, setItems] = useState([]);
- const getProductsAsync = useAsync(getProducts);
-
- useEffect(() => {
- async function handleLoadItem() {
- const data = await getProductsAsync({
- page: 1,
- pageSize: c.BEST_PRODUCT_PAGE_SIZE[viewport],
- orderBy: c.SORT_ORDER.LIKE,
- });
- if (!data) return;
-
- setItems(data.list);
- }
-
- handleLoadItem();
- }, [viewport, getProductsAsync]);
+ const getBestProductsQuery = useOwnQuery({
+ queryFn: _ => getProducts({ page: 1, pageSize: c.BEST_PRODUCT_PAGE_SIZE[viewport], orderBy: c.SORT_ORDER.LIKE }),
+ queryKey: ['bestProduct', viewport],
+ onSuccess: result => setItems(result.list),
+ });
return (
diff --git a/src/components/product/ItemRegistration.jsx b/src/components/product/ItemRegistration.jsx
new file mode 100644
index 000000000..1dca28cf3
--- /dev/null
+++ b/src/components/product/ItemRegistration.jsx
@@ -0,0 +1,241 @@
+/** @jsxImportSource @emotion/react */
+import { css } from '@emotion/react';
+import { useRouter } from 'next/router';
+import { useEffect, useRef, useState } from 'react';
+import Input from '@components/Input';
+import TagButton from '@components/product/TagButton';
+import { useAuth } from '@contexts/AuthProvider';
+import useOwnMutation from '@hooks/useOwnMutation';
+import useOwnQuery from '@hooks/useOwnQuery';
+import useValidation from '@hooks/useValidation';
+import { getOneUser, getProductDetail, patchProduct, postProduct } from '@utils/api';
+import c from '@utils/constants';
+
+const style = {
+ registrationPage: css`
+ padding-top: 2.6rem;
+ padding-bottom: 16.2rem;
+
+ @media (max-width: ${c.BREAKPOINTS.TABLET}px) {
+ margin: 1.8rem auto 19.4rem auto;
+ height: 80.6rem;
+ padding: 0 2.4rem;
+ }
+
+ @media (max-width: ${c.BREAKPOINTS.MOBILE}px) {
+ margin: 2.4rem auto 18.6rem auto;
+ padding: 0 1.6rem;
+ }
+ `,
+ title: css`
+ display: flex;
+ justify-content: space-between;
+ height: 4.2rem;
+
+ margin-bottom: 2.4rem;
+
+ p {
+ font-weight: 700;
+ font-size: 2rem;
+ line-height: 3.2rem;
+ color: var(--gray-800);
+ }
+ `,
+ registButton: css`
+ padding: 1.2rem 2.3rem;
+ border-radius: 8px;
+
+ font-weight: 600;
+ font-size: 1.6rem;
+ line-height: 2.6rem;
+ color: var(--gray-100);
+ `,
+ info: css`
+ display: flex;
+ flex-direction: column;
+ gap: 3.2rem;
+
+ @media (max-width: ${c.BREAKPOINTS.TABLET}px) {
+ gap: 2.4rem;
+ }
+ `,
+ tagButtonWrap: css`
+ margin-top: 1.4rem;
+ `,
+};
+
+export default function ItemRegistration({ productId }) {
+ const { user, tokenExpireCheck } = useAuth(true);
+ const validation = useValidation();
+ const router = useRouter();
+ const userId = useRef('');
+ const [nameObj, setNameObj] = useState({ ...c.EMPTY_INPUT_OBJ, name: 'name', type: 'text' });
+ const [descriptionObj, setDescriptionObj] = useState({ ...c.EMPTY_INPUT_OBJ, name: 'description', type: 'text' });
+ const [priceObj, setPriceObj] = useState({ ...c.EMPTY_INPUT_OBJ, name: 'price', type: 'number' });
+ const [tagObj, setTagObj] = useState({ ...c.EMPTY_INPUT_OBJ, name: 'tag', type: 'text' });
+ const [fileObj, setFileObj] = useState({ ...c.EMPTY_INPUT_OBJ, name: 'image', type: 'file' });
+ const [fileurl, setFileurl] = useState('');
+ const [tags, setTags] = useState([]);
+ const [validationCheck, setValidationCheck] = useState(
+ productId ? { name: true, description: true, price: true, tag: true } : {},
+ );
+ const [canSubmit, setCanSubmit] = useState(false);
+ const isFirstVisit = useRef(true);
+
+ const getOneUserQuery = useOwnQuery({
+ queryFn: _ => getOneUser(),
+ queryKey: ['randomUserId'],
+ onSuccess: result => (userId.current = result.list[0].id),
+ });
+ const getProductDetailQuery = useOwnQuery({
+ queryFn: _ => getProductDetail(productId),
+ queryKey: ['product', productId],
+ onSuccess: result => {
+ setNameObj(old => ({ ...old, value: result.name }));
+ setDescriptionObj(old => ({ ...old, value: result.description }));
+ setPriceObj(old => ({ ...old, value: result.price }));
+ setTags(result.tags);
+ },
+ });
+ const postProductMutation = useOwnMutation({
+ mutationFn: data => postProduct(data),
+ onSuccess: result => {
+ console.log(result);
+ router.push(`/items/${result.id}`);
+ },
+ });
+ const patchProductMutation = useOwnMutation({
+ mutationFn: data => patchProduct(productId, data),
+ invalidQueryKey: ['product', productId],
+ onSuccess: result => router.push(`/items/${result.id}`),
+ });
+
+ const handleValidation = inputObj => {
+ const { name, value } = inputObj;
+
+ // NOTE Validation
+ const errMsg = validation(name, value.trim());
+
+ // NOTE count validation
+ setValidationCheck(old => ({ ...old, [name]: true }));
+
+ // NOTE Set input Ojbect
+ let setInputObj = null;
+ switch (name) {
+ case 'name':
+ setInputObj = setNameObj;
+ break;
+ case 'description':
+ setInputObj = setDescriptionObj;
+ break;
+ case 'price':
+ setInputObj = setPriceObj;
+ break;
+ case 'tag':
+ setInputObj = setTagObj;
+ break;
+ }
+ setInputObj(old => ({ ...old, errMsg, value: value.trim() }));
+
+ // NOTE errMsg๊ฐ ์์ = validation์ ํต๊ณผํจ.
+ return !errMsg;
+ };
+ const handleAddTag = (e, inputObj, setInputValue) => {
+ if (e.key === 'Enter') {
+ const { value } = inputObj;
+ if (!value.trim()) return setTagObj(old => ({ ...old, errMsg: '๋น ๋ฌธ์๋ง ์
๋ ฅํ ์ ์์ต๋๋ค.' }));
+ if (tags.length >= 5) return setTagObj(old => ({ ...old, errMsg: 'ํ๊ทธ๋ 5๊ฐ๊น์ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค.' }));
+ if (tags.includes(value)) return setTagObj(old => ({ ...old, errMsg: '๊ฐ์ ํ๊ทธ๊ฐ ์กด์ฌํฉ๋๋ค' }));
+ if (!handleValidation(inputObj)) return null;
+
+ setTags(old => [...old, value]);
+ setTagObj(old => ({ ...old, value: '' }));
+ setInputValue('');
+ }
+ };
+ const handleRemoveTag = name => {
+ const idx = tags.findIndex(t => t === name);
+ const newTags = [...tags.slice(0, idx), ...tags.slice(idx + 1)];
+ setTags(newTags);
+ };
+ const handleChnageImage = e => {
+ const fileArray = Array.from(e.target.files);
+ const file = fileArray[0];
+ setFileObj(old => ({ ...old, value: file }));
+ setFileurl(URL.createObjectURL(file));
+ };
+ const handleSubmit = async () => {
+ if (!tokenExpireCheck()) router.push('/items');
+ const formData = new FormData();
+ formData.append('file', fileObj.value);
+
+ const data = {
+ name: nameObj.value,
+ description: descriptionObj.value,
+ price: parseInt(priceObj.value),
+ tags,
+ ownerId: userId.current,
+ };
+ formData.append('data', JSON.stringify(data));
+
+ productId ? patchProductMutation.mutate(formData) : postProductMutation.mutate(formData);
+ };
+
+ useEffect(() => {
+ if (isFirstVisit.current) {
+ isFirstVisit.current = false;
+ if (!productId) return setCanSubmit(false);
+ return setCanSubmit(true);
+ }
+
+ // NOTE 4๊ฐ ํญ๋ชฉ์ ์ต์ด Validation์ด ์งํ๋์ง ์์์ ๊ฒฝ์ฐ, false๋ฅผ ๋ฆฌํด.
+ if (Object.keys(validationCheck).length !== 4) return setCanSubmit(false);
+
+ // NOTE validation์ ํต๊ณผํ๋๊ฐ? + ํ๊ทธ๊ฐ 1๊ฐ ์ด์์ธ๊ฐ?
+ const isOk = !(nameObj.errMsg || descriptionObj.errMsg || priceObj.errMsg || tagObj.errMsg) && tags.length > 0;
+
+ return setCanSubmit(isOk);
+ }, [nameObj.errMsg, descriptionObj.errMsg, priceObj.errMsg, tagObj.errMsg, tags, validationCheck]);
+
+ if (!user) return null;
+ return (
+
+ );
+}
diff --git a/src/components/product/ProductCard.jsx b/src/components/product/ProductCard.jsx
index 6646c9c8f..d75eef6aa 100644
--- a/src/components/product/ProductCard.jsx
+++ b/src/components/product/ProductCard.jsx
@@ -1,8 +1,8 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import { priceFunc } from '@/src/utils/utils.js';
-import c from '@/src/utils/constants.js';
import Image from 'next/image';
+import c from '@utils/constants';
+import { toPriceString } from '@utils/utils';
const style = {
card: css`
@@ -60,12 +60,12 @@ const style = {
export default function ProductCard({ item, best = false }) {
const { likeCount, price, name, images } = item;
const imgUrl = images?.[0] || '/Image/img_default.png';
- const priceString = priceFunc(price);
+ const priceString = toPriceString(price);
return (
-
+
{name}
diff --git a/src/components/product/ProductOnSaleTitle.jsx b/src/components/product/ProductOnSaleTitle.jsx
index e3bd81089..e3713d62d 100644
--- a/src/components/product/ProductOnSaleTitle.jsx
+++ b/src/components/product/ProductOnSaleTitle.jsx
@@ -1,10 +1,10 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import { useViewport } from '../../contexts/ViewportContext.jsx';
-import c from '../../utils/constants.js';
import Link from 'next/link';
-import SearchBar from '../SearchBar.jsx';
-import SortOrderSelect from '../SortOrderSelect';
+import SearchBar from '@components/SearchBar';
+import SortOrderSelect from '@components/SortOrderSelect';
+import { useViewport } from '@contexts/ViewportProvider';
+import c from '@utils/constants';
const style = {
productOnSaleTitle: css`
@@ -65,7 +65,7 @@ export default function ProductOnSaleTitle({ onSearch }) {
const viewport = useViewport();
const registBtn = (
-
+
์ํ ๋ฑ๋กํ๊ธฐ
diff --git a/src/components/product/ProductsOnSale.jsx b/src/components/product/ProductsOnSale.jsx
index a2096b1b1..95d3f927c 100644
--- a/src/components/product/ProductsOnSale.jsx
+++ b/src/components/product/ProductsOnSale.jsx
@@ -1,14 +1,15 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import { useCallback, useEffect, useState } from 'react';
-import { getProducts } from '../../utils/api.js';
-import useAsync from '../../hooks/useAsync.js';
-import ProductCard from './ProductCard.jsx';
-import PaginationBar from '../PaginationBar.jsx';
-import ProductOnSaleTitle from './ProductOnSaleTitle.jsx';
-import { useViewport } from '../../contexts/ViewportContext.jsx';
-import c from '../../utils/constants.js';
-import { useDropdownItem } from '../../contexts/DropdownContext.jsx';
+import Link from 'next/link';
+import { useState } from 'react';
+import PaginationBar from '@components/PaginationBar';
+import ProductCard from '@components/product/ProductCard';
+import ProductOnSaleTitle from '@components/product/ProductOnSaleTitle';
+import { useDropdownItem } from '@contexts/DropdownProvider';
+import { useViewport } from '@contexts/ViewportProvider';
+import useOwnQuery from '@hooks/useOwnQuery';
+import { getProducts } from '@utils/api';
+import c from '@utils/constants';
const style = {
productOnSale: css`
@@ -63,36 +64,34 @@ export default function ProductsOnSale() {
const [totalCount, setTotalCount] = useState(0);
const [now, setNow] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
- const getProductsAsync = useAsync(getProducts);
- const handleSearch = query => setSearchQuery(query);
- const handlePageChange = useCallback(p => setNow(p), []);
-
- useEffect(() => {
- async function handleLoadProducts() {
- const data = await getProductsAsync({
+ const getProductsQuery = useOwnQuery({
+ queryFn: _ =>
+ getProducts({
page: now,
pageSize: c.PRODUCT_PAGE_SIZE[viewport],
orderBy: sortOrder,
keyword: searchQuery,
- });
- if (!data) return null;
+ }),
+ queryKey: ['products', now, sortOrder, searchQuery, viewport],
+ onSuccess: result => {
+ setProducts(result.list);
+ setTotalCount(result.totalCount);
+ },
+ });
- setProducts(data.list);
- setTotalCount(data.totalCount);
- setNow(now);
- }
-
- handleLoadProducts();
- }, [viewport, now, sortOrder, searchQuery, getProductsAsync]);
+ const handleSearch = query => setSearchQuery(query);
+ const handlePageChange = p => setNow(p);
return (
- {products.map(product => {
- return
;
- })}
+ {products.map(product => (
+
+
+
+ ))}
diff --git a/src/components/product/TagButton.jsx b/src/components/product/TagButton.jsx
index d65c1b4a3..6003c91c8 100644
--- a/src/components/product/TagButton.jsx
+++ b/src/components/product/TagButton.jsx
@@ -2,31 +2,32 @@
import { css } from '@emotion/react';
import Image from 'next/image';
-const style = {
- tagButton: css`
- background-color: var(--gray-100);
- display: inline-flex;
- align-items: center;
- justify-content: center;
+export default function TagButton({ name, onClick }) {
+ const style = {
+ tagButton: css`
+ cursor: ${onClick ? 'pointer' : 'default'};
+ background-color: var(--gray-100);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
- padding: 0.6rem 1.2rem 0.6rem 1.6rem;
- border-radius: 26px;
+ padding: 0.6rem 1.2rem 0.6rem 1.6rem;
+ border-radius: 26px;
- height: 3.6rem;
+ height: 3.6rem;
- img {
- margin-left: 0.8rem;
- }
- `,
-};
+ img {
+ margin-left: 0.8rem;
+ }
+ `,
+ };
-export default function TagButton({ name, onClick }) {
- const handleClick = () => onClick(name);
+ const handleClick = onClick ? () => onClick(name) : null;
return (
{`#${name}`}
-
+ {onClick && }
);
}
diff --git a/src/contexts/AuthProvider.jsx b/src/contexts/AuthProvider.jsx
new file mode 100644
index 000000000..1cba7da7e
--- /dev/null
+++ b/src/contexts/AuthProvider.jsx
@@ -0,0 +1,113 @@
+import { useRouter } from 'next/router';
+import { createContext, useContext, useEffect, useState } from 'react';
+import useAsync from '@hooks/useAsync';
+import { getMe as getMeApi, refreshToken, signIn } from '@utils/api';
+import { isTokenExpired } from '@utils/utils';
+import { useSetError } from './ErrorProvider';
+
+const AuthContext = createContext({
+ user: null,
+ isPending: true,
+ login: () => {},
+ logout: () => {},
+ updateMe: () => {},
+});
+
+export default function AuthProvider({ children }) {
+ const [userObj, setUserObj] = useState({ user: null, isPending: true });
+ const router = useRouter();
+ const signInAsync = useAsync(signIn);
+ const refreshTokenAsync = useAsync(refreshToken);
+ const setError = useSetError();
+
+ const login = async ({ email, password }) => {
+ const res = await signInAsync({ email, password });
+ if (!res) return null;
+
+ localStorage.setItem('accessToken', res.accessToken);
+ return getMe();
+ };
+ const logout = () => {
+ localStorage.removeItem('accessToken');
+ setUserObj(old => ({ ...old, user: null, isPending: false }));
+ };
+ const getMe = async () => {
+ await tokenExpireCheck();
+
+ const accessToken = localStorage.getItem('accessToken');
+
+ setUserObj(old => ({ ...old, isPending: true }));
+
+ let nextUser = null;
+ try {
+ nextUser = await getMeApi({ headers: { Authorization: `Bearer ${accessToken}` } });
+ console.log('๐ ~ getMe ~ nextUser:', nextUser);
+ return nextUser;
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setUserObj(old => ({ ...old, user: nextUser, isPending: false }));
+ }
+ };
+ const updateMe = () => {};
+ const tokenExpireCheck = async () => {
+ const accessToken = localStorage.getItem('accessToken');
+ // NOTE accessToken์ ์ ํจ๊ธฐ๊ฐ ๊ฒ์ฌ ํ ๋ง๋ฃ์
+ if (accessToken && isTokenExpired(accessToken)) {
+ const newAccessToken = await refreshNewAccessToken();
+ if (newAccessToken) {
+ localStorage.setItem('accessToken', newAccessToken);
+ return true;
+ }
+
+ setError(new Error('๋ก๊ทธ์ธ์ด ๋ง๋ฃ๋์์ต๋๋ค.'));
+ logout();
+ return false;
+ }
+ // NOTE accessToken์ด ์์ = ๋ก๊ทธ์์ ์ํ
+ if (!accessToken) {
+ const newAccessToken = await refreshNewAccessToken();
+ if (newAccessToken) return localStorage.setItem('accessToken', newAccessToken);
+ }
+
+ return true;
+ };
+ const refreshNewAccessToken = async () => {
+ refreshTokenAsync();
+ // const refreshToken = localStorage.getItem('refreshToken');
+ // if (!refreshToken || isTokenExpired(refreshToken)) return null;
+
+ // const refreshResult = await refreshTokenApi({ refreshToken });
+ // localStorage.setItem('accessToken', refreshResult.accessToken);
+ // return refreshResult.accessToken;
+ };
+
+ useEffect(() => {
+ getMe();
+
+ // NOTE ํ์ด์ง ๋ณ๊ฒฝ ์ token ์ฒดํฌ
+ router.events.on('routeChangeStart', async () => await tokenExpireCheck());
+
+ return () => router.events.off('routeChangeStart', async () => await tokenExpireCheck());
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth(required = false) {
+ const context = useContext(AuthContext);
+ const router = useRouter();
+
+ if (!context) throw new Error(`Don't use useAuth() out of AuthProvider`);
+
+ useEffect(() => {
+ // NOTE ์ ์ ๊ฐ ํ์์ธ ํ์ด์ง์ ์ ์ํ์ ๋, ์ ์ ์ ๋ณด๊ฐ ์์ผ๋ฉฐ ์ ์ ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ค๋ ์ค์ด ์๋๋ผ๋ฉด
+ if (required && !context.user && !context.isPending) router.push('/auth/signIn');
+ }, [context.user, context.isPending, required]);
+
+ return context;
+}
diff --git a/src/contexts/DropdownProvider.jsx b/src/contexts/DropdownProvider.jsx
new file mode 100644
index 000000000..949203569
--- /dev/null
+++ b/src/contexts/DropdownProvider.jsx
@@ -0,0 +1,26 @@
+import { createContext, useContext, useState } from 'react';
+
+const DropdownContext = createContext();
+
+export default function DropdownProvider({ children }) {
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+ const [item, setItem] = useState();
+
+ return
{children};
+}
+
+export function useDropdown() {
+ const context = useContext(DropdownContext);
+ if (!context) throw new Error(`Don't use useDropdown() out of DropdownProvider`);
+ const { dropdownOpen, setDropdownOpen } = context;
+
+ return { dropdownOpen, setDropdownOpen };
+}
+
+export function useDropdownItem() {
+ const context = useContext(DropdownContext);
+ if (!context) throw new Error(`Don't use useDropdownItem() out of DropdownProvider`);
+ const { item, setItem } = context;
+
+ return { item, setItem };
+}
diff --git a/src/contexts/ErrorProvider.jsx b/src/contexts/ErrorProvider.jsx
new file mode 100644
index 000000000..14158bee1
--- /dev/null
+++ b/src/contexts/ErrorProvider.jsx
@@ -0,0 +1,23 @@
+import { createContext, useContext, useState } from 'react';
+
+const ErrorContext = createContext();
+
+export default function ErrorProvider({ defaultError = null, children }) {
+ const [error, setError] = useState(defaultError);
+
+ return
{children};
+}
+
+export function useError() {
+ const context = useContext(ErrorContext);
+ if (!context) throw new Error(`Don't use useError() out of ErrorProvider`);
+
+ return context.error;
+}
+
+export function useSetError() {
+ const context = useContext(ErrorContext);
+ if (!context) throw new Error(`Don't use useSetError() out of ErrorProvider`);
+
+ return context.setError;
+}
diff --git a/src/contexts/PendingProvider.jsx b/src/contexts/PendingProvider.jsx
new file mode 100644
index 000000000..8f6328992
--- /dev/null
+++ b/src/contexts/PendingProvider.jsx
@@ -0,0 +1,32 @@
+import { createContext, useContext, useEffect, useState } from 'react';
+
+const PendingContext = createContext();
+
+export default function PendingProvider({ defaultPending = false, children }) {
+ const [pending, setPending] = useState(defaultPending);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // ์๊ฐ ๊ฒฝ๊ณผ ์ฒดํฌ๋ฅผ ์ํ useEffect
+ useEffect(() => {
+ if (!pending) return setIsLoading(false);
+ const tick = setTimeout(() => setIsLoading(true), 2000);
+
+ return () => clearTimeout(tick);
+ }, [pending]);
+
+ return
{children};
+}
+
+export function useIsLoading() {
+ const context = useContext(PendingContext);
+ if (!context) throw new Error(`Don't use useIsLoading() out of PendingProvider`);
+
+ return context.isLoading;
+}
+
+export function useSetPending() {
+ const context = useContext(PendingContext);
+ if (!context) throw new Error(`Don't use useSetPending() out of PendingProvider`);
+
+ return context.setPending;
+}
diff --git a/src/contexts/ViewportProvider.jsx b/src/contexts/ViewportProvider.jsx
new file mode 100644
index 000000000..a7ac766d1
--- /dev/null
+++ b/src/contexts/ViewportProvider.jsx
@@ -0,0 +1,35 @@
+import { createContext, useContext, useEffect, useState } from 'react';
+import c from '@utils/constants';
+
+const ViewportContext = createContext();
+
+export default function ViewportProvider({ children }) {
+ const [viewport, setViewport] = useState(() => {
+ const width = window.innerWidth;
+
+ if (width <= c.BREAKPOINTS.MOBILE) return c.VIEWPORT.MOBILE;
+ if (width <= c.BREAKPOINTS.TABLET) return c.VIEWPORT.TABLET;
+ return c.VIEWPORT.PC;
+ });
+
+ useEffect(() => {
+ const handleResize = () => {
+ const width = window.innerWidth;
+
+ if (width <= c.BREAKPOINTS.MOBILE) return setViewport(c.VIEWPORT.MOBILE);
+ if (width <= c.BREAKPOINTS.TABLET) return setViewport(c.VIEWPORT.TABLET);
+ return setViewport(c.VIEWPORT.PC);
+ };
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ return
{children};
+}
+
+export function useViewport() {
+ const context = useContext(ViewportContext);
+ if (!context) throw new Error(`Don't use useViewport() out of ViewportProvider`);
+
+ return context.viewport;
+}
diff --git a/src/hooks/useAsync.js b/src/hooks/useAsync.js
index e7fb1b298..9a1d95f60 100644
--- a/src/hooks/useAsync.js
+++ b/src/hooks/useAsync.js
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
-import { useSetError } from '../contexts/ErrorContext.jsx';
-import { useSetPending } from '../contexts/PendingContext.jsx';
+import { useSetError } from '@contexts/ErrorProvider';
+import { useSetPending } from '@contexts/PendingProvider';
export default function useAsync(asyncFunc) {
const setPending = useSetPending();
diff --git a/src/hooks/useOwnMutation.js b/src/hooks/useOwnMutation.js
new file mode 100644
index 000000000..6cc34be92
--- /dev/null
+++ b/src/hooks/useOwnMutation.js
@@ -0,0 +1,28 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useEffect } from 'react';
+import { useSetError } from '@contexts/ErrorProvider';
+import { useSetPending } from '@contexts/PendingProvider';
+
+/**
+ * useMutation Wrapper Function
+ *
+ * @param {{ mutationFn: Function; invalidQueryKey?: string[]; onSuccess?: Function; }}
+ */
+export default function useOwnMutation({ mutationFn, invalidQueryKey, onSuccess }) {
+ const queryClient = useQueryClient();
+ const setPending = useSetPending();
+ const setErr = useSetError();
+
+ const mutation = useMutation({
+ mutationFn,
+ onError: error => setErr(error),
+ onSuccess: (data, variables, context) => {
+ onSuccess && onSuccess(data, variables, context);
+ invalidQueryKey && queryClient.invalidateQueries(invalidQueryKey);
+ },
+ });
+
+ useEffect(() => setPending(mutation.isPending), [mutation.isPending]);
+
+ return mutation;
+}
diff --git a/src/hooks/useOwnQuery.js b/src/hooks/useOwnQuery.js
new file mode 100644
index 000000000..21fc85534
--- /dev/null
+++ b/src/hooks/useOwnQuery.js
@@ -0,0 +1,33 @@
+import { keepPreviousData, useQuery } from '@tanstack/react-query';
+import { useEffect } from 'react';
+import { useSetError } from '@contexts/ErrorProvider';
+import { useSetPending } from '@contexts/PendingProvider';
+
+/**
+ * useQuery Wrapper Function
+ *
+ * @param {{ queryFn: Function; queryKey: string[]; onSuccess?: Function; enabled?: boolean; }}
+ */
+export default function useOwnQuery({ queryFn, queryKey, onSuccess, enabled = true }) {
+ const isValidKey = Array.isArray(queryKey) && queryKey.every(key => key !== undefined && key !== null);
+
+ const setPending = useSetPending();
+ const setErr = useSetError();
+
+ const query = useQuery({
+ queryFn,
+ queryKey: isValidKey ? queryKey : ['invalid-key'],
+ enabled: enabled && isValidKey,
+ placeholderData: keepPreviousData,
+ });
+
+ useEffect(() => {
+ if (isValidKey) {
+ setPending(query.isPending);
+ if (query.isError) setErr(query.error);
+ if (query.isSuccess && onSuccess) onSuccess(query.data);
+ }
+ }, [query.isError, query.isPending, query.isSuccess, query.data]);
+
+ return query;
+}
diff --git a/src/hooks/usePagination.js b/src/hooks/usePagination.js
index fb9cd9a35..c5a1a4942 100644
--- a/src/hooks/usePagination.js
+++ b/src/hooks/usePagination.js
@@ -1,59 +1,50 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useEffect, useState } from 'react';
+import c from '@utils/constants';
-export default function usePagination(totalCounts, pageSize, bundleSize, onPageChange, initialPage = 1, initialBundleCount = 1) {
+export default function usePagination(totalCounts, pageSize, bundleSize = c.BUNDLE_SIZE, initialPage = 1) {
const [currentPage, setCurrentPage] = useState(initialPage);
- const [totalPages, setTotalPages] = useState(Math.ceil(totalCounts / pageSize));
- const [bundleCount, setBundleCount] = useState(initialBundleCount);
- const [bundle, setBundle] = useState([]);
- const [totalBundleCounts, setTotalBundleCounts] = useState(Math.ceil(totalPages / bundleSize));
+ const [currentBundle, setCurrentBundle] = useState(1);
- const goToPage = useCallback(
- page => {
- setCurrentPage(Math.max(Math.min(page, totalPages), 1));
- onPageChange(page);
- },
- [totalPages, onPageChange],
- );
-
- const nextBundle = () => {
- setBundleCount(prev => Math.min(prev + 1, totalBundleCounts));
- };
-
- const prevBundle = () => {
- setBundleCount(prev => Math.max(prev - 1, 1));
- };
-
- // const nextPage = () => {
- // setCurrentPage((prev) => Math.min(prev + 1, totalPages));
- // };
-
- // const prevPage = () => {
- // setCurrentPage((prev) => Math.max(prev - 1, 1));
- // };
+ const totalPages = Math.ceil(totalCounts / pageSize);
+ const totalBundles = Math.ceil(totalPages / bundleSize);
- useEffect(() => {
- // ์ด ์์ดํ
or ํ์ด์ง ๋น ๊ฐ์๊ฐ ๋ณ๊ฒฝ๋๋ฉด ์ด ํ์ด์ง ์ ์ฌ๊ณ์ฐ
- setTotalPages(Math.ceil(totalCounts / pageSize));
- }, [totalCounts, pageSize]);
+ const head = (currentBundle - 1) * bundleSize + 1;
+ const bundlePages = [];
- useEffect(() => {
- // ์ด ํ์ด์ง or ๋ฒ๋ค ๋น ๊ฐ์๊ฐ ๋ณ๊ฒฝ๋๋ฉด ์ด ๋ฒ๋ค ์ ์ฌ๊ณ์ฐ
- setTotalBundleCounts(Math.ceil(totalPages / bundleSize));
- }, [totalPages, bundleSize]);
+ for (let i = 0; i < bundleSize && head + i <= totalPages; i++) {
+ bundlePages.push(head + i);
+ }
- useEffect(() => {
- const newBundle = [];
- const head = (bundleCount - 1) * bundleSize + 1;
-
- for (let i = 0; i < bundleSize; i++) {
- const page = head + i;
- if (page > totalPages) break;
- newBundle.push(page);
+ const goToPage = page => setCurrentPage(Math.max(Math.min(page, totalPages), 1));
+ const getNextBundle = _ => {
+ if (currentBundle < totalBundles) {
+ setCurrentBundle(currentBundle + 1);
+ goToPage(currentBundle * bundleSize + 1);
+ }
+ };
+ const getPrevBundle = _ => {
+ if (currentBundle > 1) {
+ setCurrentBundle(currentBundle - 1);
+ goToPage((currentBundle - 2) * bundleSize + 1);
}
+ };
- setBundle(newBundle);
- goToPage(head);
- }, [bundleCount, totalPages, bundleSize, goToPage]);
+ useEffect(
+ _ => {
+ const newBundle = Math.ceil(currentPage / bundleSize);
+ if (newBundle !== currentBundle) setCurrentBundle(newBundle);
+ },
+ [currentPage, bundleSize],
+ );
- return { currentPage, bundle, bundleCount, totalBundleCounts, goToPage, nextBundle, prevBundle };
+ return {
+ currentPage,
+ currentBundle,
+ bundlePages,
+ goToPage,
+ getNextBundle,
+ getPrevBundle,
+ canGoPrev: currentBundle > 1,
+ canGoNext: currentBundle < totalBundles,
+ };
}
diff --git a/src/hooks/useValidation.js b/src/hooks/useValidation.js
index a1c470cdd..9b552a005 100644
--- a/src/hooks/useValidation.js
+++ b/src/hooks/useValidation.js
@@ -32,7 +32,7 @@ function nameValidation(value) {
function descriptionValidation(value) {
let errMsg;
- if (value?.length <= 10) {
+ if (value?.trim?.().length <= 10) {
errMsg = '10์ ์ด์ ์
๋ ฅํด์ฃผ์ธ์';
} else if (value?.length > 100) {
errMsg = '100์ ์ด๋ด๋ก ์
๋ ฅํด์ฃผ์ธ์';
diff --git a/src/layouts/SignLayout.jsx b/src/layouts/SignLayout.jsx
index 6cc44097b..ce746325f 100644
--- a/src/layouts/SignLayout.jsx
+++ b/src/layouts/SignLayout.jsx
@@ -1,6 +1,6 @@
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
-import c from '../utils/constants.js';
+import c from '@utils/constants';
const style = {
signLayout: css`
diff --git a/src/utils/api.js b/src/utils/api.js
index e5814fb6a..b96213ac5 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -1,46 +1,251 @@
-import { axiosDelete, axiosGet, axiosPatch, axiosPost } from './axiosUtils.js';
+import { axiosDelete, axiosGet, axiosPatch, axiosPost, axiosPut } from '@utils/axiosUtils';
-// const SERVER = 'https://panda-market-api.vercel.app/products';
// const SERVER = 'https://pandamarket-be.onrender.com/products'; // mongodb
// const SERVER = 'https://pandamarket-be-postgres.onrender.com/products'; // postgres
const SERVER = `http://localhost:3000`;
+const CODEIT_SERVER = 'https://panda-market-api.vercel.app';
+//#region user
+/**
+ * @async
+ * @param {object} [headers={}]
+ */
+// export async function getMe(headers = {}) {
+// return axiosGet({ base: CODEIT_SERVER, url: '/users/me', headers });
+// }
+//#endregion
+
+//#region product
+/**
+ * @async
+ * @param {object} [params={}]
+ */
export async function getProducts(params = {}) {
- return await axiosGet(SERVER, '/products', params);
+ return axiosGet({ base: SERVER, url: '/products', params });
+}
+
+/**
+ * @async
+ * @param {uuid} id
+ * @param {object} [params={}]
+ */
+export async function getProductDetail(id, params = {}) {
+ return axiosGet({ base: SERVER, url: `/products/${id}`, params });
+}
+
+/**
+ * @async
+ * @param {{
+ * images: string[]
+ * tags: string[]
+ * price: int
+ * description: string
+ * name: string
+ * }} [data={}]
+ */
+export async function postProduct(data = {}) {
+ return axiosPost({ base: SERVER, url: '/products', data, headers: { 'Content-Type': 'multipart/form-data' } });
+}
+
+/**
+ * @async
+ * @param {uuid} id
+ * @param {{
+ * images?: string[]
+ * tags?: string[]
+ * price?: int
+ * description?: string
+ * name?: string
+ * }} [data={}]
+ */
+export async function patchProduct(id, data = {}) {
+ return axiosPatch({ base: SERVER, url: `/products/${id}`, data });
+}
+
+/**
+ * @async
+ * @param {uuid} id
+ */
+export async function deleteProduct(id) {
+ return axiosDelete({ base: SERVER, url: `/products/${id}` });
+}
+
+/**
+ * @async
+ * @param {uuid} id
+ */
+export async function postProductLike(id) {
+ return axiosPost({ base: SERVER, url: `/products/${id}/like` });
+}
+
+/**
+ * @async
+ * @param {uuid} id
+ */
+export async function deleteProductLike(id) {
+ return axiosDelete({ base: SERVER, url: `/products/${id}/like` });
+}
+
+/**
+ * @async
+ * @param {uuid} id
+ * @param {object} [params={}]
+ */
+export async function getCommentsOfProduct(id, params = {}) {
+ return axiosGet({ base: SERVER, url: `/products/${id}/comments`, params });
+}
+
+/**
+ * @async
+ * @param {uuid} id
+ * @param {{content: string}} [data={}]
+ */
+export async function postCommentOfProduct(id, data = {}) {
+ return axiosPost({ base: SERVER, url: `/products/${id}/comments`, data });
}
+//#endregion
//#region article
+/**
+ * @async
+ * @param {object} [params={}]
+ */
export async function getArticles(params = {}) {
- return await axiosGet(SERVER, '/articles', params);
+ return axiosGet({ base: SERVER, url: '/articles', params });
}
+/**
+ * @async
+ * @param {uuid} id
+ * @param {object} [params={}]
+ */
export async function getArticleById(id, params = {}) {
- return await axiosGet(SERVER, `/articles/${id}`, params);
+ return axiosGet({ base: SERVER, url: `/articles/${id}`, params });
}
+/**
+ * @async
+ * @param {uuid} id
+ * @param {object} [params={}]
+ */
export async function getCommentsOfArticle(id, params = {}) {
- return await axiosGet(SERVER, `/articles/${id}/comments`, params);
+ return axiosGet({ base: SERVER, url: `/articles/${id}/comments`, params });
}
+/**
+ * @async
+ * @param {uuid} id
+ * @param {{content: string, ownerId: uuid}} [data={}]
+ */
export async function postCommentOfArticle(id, data = {}) {
- return await axiosPost(SERVER, `/articles/${id}/comments`, data);
+ return axiosPost({ base: SERVER, url: `/articles/${id}/comments`, data });
}
+/**
+ * @async
+ * @param {{
+ * title: string,
+ * content: string,
+ * images?: string[],
+ * ownerId: Uuid
+ * }} [data={}]
+ */
export async function postArticle(data = {}) {
- return await axiosPost(SERVER, '/articles', data);
+ return axiosPost({ base: SERVER, url: '/articles', data });
}
+/**
+ * @async
+ * @param {uuid} id
+ * @param {{
+ * title?: string,
+ * content?: string,
+ * images?: string[],
+ * }} [data={}]
+ */
export async function patchArticle(id, data = {}) {
- return await axiosPatch(SERVER, `/articles/${id}`, data);
+ return axiosPatch({ base: SERVER, url: `/articles/${id}`, data });
+}
+
+/**
+ * @async
+ * @param {uuid} id
+ */
+export async function deleteArticle(id) {
+ return axiosDelete({ base: SERVER, url: `/articles/${id}` });
}
//#endregion
//#region comment
+/**
+ * @async
+ * @param {uuid} id
+ * @param {{content: string}} [data={}]
+ */
export async function patchComment(id, data = {}) {
- return await axiosPatch(SERVER, `/comments/${id}`, data);
+ return axiosPatch({ base: CODEIT_SERVER, url: `/comments/${id}`, data });
}
+/**
+ * @async
+ * @param {uuid} id
+ * @param {{
+ * id: uuid
+ * content: string
+ * ownerId: uuid
+ * articleId: uuid
+ * productId: uuid
+ * createdAt: DateTime
+ * updatedAt: DateTime
+ * }} [data={}]
+ */
+export async function putComment(id, data = {}) {
+ return axiosPut({ base: SERVER, url: `/comments/${id}`, data });
+}
+
+/**
+ * @async
+ * @param {uuid} id
+ */
export async function deleteComment(id) {
- return await axiosDelete(SERVER, `/comments/${id}`);
+ return axiosDelete({ base: CODEIT_SERVER, url: `/comments/${id}` });
}
//#endregion
+
+//#region auth
+/**
+ * @async
+ * @param {object} [headers={}]
+ */
+export async function getMe(headers = {}) {
+ return axiosGet({ base: SERVER, url: '/auth/me', headers });
+}
+
+/**
+ * @async
+ * @param {{ email: string; nickname: string; password: string; passwordConfirmation: string; }} [data={}]
+ */
+export async function signUp(data = {}) {
+ return axiosPost({ base: SERVER, url: '/auth/signUp', data });
+}
+
+/**
+ * @async
+ * @param {{email: string, password: string}} [data={}]
+ */
+export async function signIn(data = {}) {
+ return axiosPost({ base: SERVER, url: '/auth/signIn', data });
+}
+
+/**
+ * @async
+ * @param {{refreshToken: string}} [data={}]
+ */
+export async function refreshToken(data = {}) {
+ return axiosPost({ base: SERVER, url: '/auth/refresh', data });
+}
+//#endregion
+
+export async function getOneUser(params = { pageSize: 1 }) {
+ return axiosGet({ base: SERVER, url: '/dev/users', params });
+}
diff --git a/src/utils/axiosUtils.js b/src/utils/axiosUtils.js
index 54c8752b5..f2c969149 100644
--- a/src/utils/axiosUtils.js
+++ b/src/utils/axiosUtils.js
@@ -1,6 +1,5 @@
import axios from 'axios';
-import c from './constants.js';
-import { isEmpty } from './utils.js';
+import { isEmpty } from '@utils/utils';
const HTTP_METHODS = Object.freeze({
GET: 'GET',
@@ -10,41 +9,122 @@ const HTTP_METHODS = Object.freeze({
PUT: 'PUT',
});
-async function axiosData({ base, url, method, data = {}, params = {} }) {
+const HTTP_STATUS = Object.freeze({
+ SUCCESS: 200,
+ CREATED: 201,
+ ACCEPTED: 202,
+ NON_AUTHORITATIVE_INFORMATION: 203,
+ NO_CONTENT: 204,
+ BAD_REQUEST: 400,
+ UNAUTHORIZED: 401,
+ FORBIDDEN: 403,
+ NOT_FOUND: 404,
+ SERVER_ERROR: 500,
+});
+
+/**
+ * ์ค์ ๋ก axios๋ฅผ ์ด์ฉํด ํต์ ํ๊ณ ๋ฐ์ดํฐ๋ฅผ ์ฒ๋ฆฌ
+ *
+ * @async
+ * @param {{ base: string, url: string, method: HTTP_METHODS, data?: object, params?: object, headers?: object }}
+ */
+async function axiosData({ base, url, method, data = {}, params = {}, headers = { 'Content-Type': 'application/json' } }) {
if (!url || !method) throw new Error('URL and Method is required');
if (!HTTP_METHODS[method]) throw new Error('Invalid HTTP method');
const instance = axios.create({
baseURL: base,
- headers: {
- 'Content-Type': 'application/json',
- },
+ headers,
+ validateStatus: status => 200 <= status && status < 300, // res.ok์ ๋์ผํ ์กฐ๊ฑด
});
+ // NOTE ์์ฒญ ์ธํฐ์
ํฐ
+ instance.interceptors.request.use(
+ config => {
+ const token = localStorage.getItem('accessToken');
+
+ // NOTE ํ ํฐ์ด ์กด์ฌํ๋ฉด ์์ฒญ ํค๋์ ์ฒจ๋ถ
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+
+ return config;
+ },
+ error => {
+ return Promise.reject(error);
+ },
+ );
+
+ // NOTE ์๋ต ์ธํฐ์
ํฐ
+ instance.interceptors.response.use(
+ // NOTE validateStatus๋ฅผ ํต๊ณผํจ == res.ok
+ // ๋ฐํ๋ response๋ฅผ ๊ฐ๊ณตํด์ ๋ฐํํ๋ค.
+ response => ({ ...response, data: response.data.data || response.data }), // NOTE data๊ฐ ์ค์ฒฉ๋ ๊ฒฝ์ฐ์ ์ผ๊ด์ ์ผ๋ก ์ฌ์ฉํ ์ ์๋๋ก ํ๋ ์ฝ๋
+ // NOTE validateStatus๋ฅผ ํต๊ณผํ์ง ๋ชปํจ == !res.ok
+ // ๋ฐํ๋ error๋ฅผ ์ด์ฉํด ์๋ฌ์ฒ๋ฆฌ๋ฅผ ํ๋ค.
+ error => {
+ // NOTE ์๋ฌ ์๋ต ์์ธ ๋ด์ฉ ํ์ธ
+ console.error('Error status:', error.response?.status);
+ console.error('Error data:', error.response?.data);
+ console.error('Request data:', error.config?.data);
+ return Promise.reject(error);
+ },
+ );
+
const res = await instance({ method, url, data, params });
// NOTE 204 case
- if (res.status === c.HTTP_STATUS.NO_CONTENT && isEmpty(res.data)) return res.status;
+ if (res.status === HTTP_STATUS.NO_CONTENT && isEmpty(res.data)) return res.status;
return res.data;
}
-export async function axiosGet(base, url, params) {
- return axiosData({ base, url, method: HTTP_METHODS.GET, params });
+/**
+ * axios get์์ฒญ ๋ํผํจ์
+ *
+ * @async
+ * @param {{base: string, url: string, params: object}}
+ */
+export async function axiosGet({ base, url, params, headers }) {
+ return axiosData({ base, url, method: HTTP_METHODS.GET, params, headers });
}
-export async function axiosPost(base, url, data) {
- return axiosData({ base, url, method: HTTP_METHODS.POST, data });
+/**
+ * axios post์์ฒญ ๋ํผํจ์
+ *
+ * @async
+ * @param {{base: string, url: string, data: object}}
+ */
+export async function axiosPost({ base, url, data, headers }) {
+ return axiosData({ base, url, method: HTTP_METHODS.POST, data, headers });
}
-export async function axiosPatch(base, url, data) {
- return axiosData({ base, url, method: HTTP_METHODS.PATCH, data });
+/**
+ * axios patch์์ฒญ ๋ํผํจ์
+ *
+ * @async
+ * @param {{base: string, url: string, data: object}}
+ */
+export async function axiosPatch({ base, url, data, headers }) {
+ return axiosData({ base, url, method: HTTP_METHODS.PATCH, data, headers });
}
-export async function axiosPut(base, url, data) {
- return axiosData({ base, url, method: HTTP_METHODS.PUT, data });
+/**
+ * axios put์์ฒญ ๋ํผํจ์
+ *
+ * @async
+ * @param {{base: string, url: string, data: object}}
+ */
+export async function axiosPut({ base, url, data, headers }) {
+ return axiosData({ base, url, method: HTTP_METHODS.PUT, data, headers });
}
-export async function axiosDelete(base, url, data) {
- return axiosData({ base, url, method: HTTP_METHODS.DELETE, data });
+/**
+ * axios delete์์ฒญ ๋ํผํจ์
+ *
+ * @async
+ * @param {{base: string, url: string, params: object}}
+ */
+export async function axiosDelete({ base, url, params, headers }) {
+ return axiosData({ base, url, method: HTTP_METHODS.DELETE, params, headers });
}
diff --git a/src/utils/constants.js b/src/utils/constants.js
index 532e4c96d..4298cafc0 100644
--- a/src/utils/constants.js
+++ b/src/utils/constants.js
@@ -51,16 +51,4 @@ export default class CONSTANTS {
type: '',
errMsg: '',
});
- static HTTP_STATUS = Object.freeze({
- SUCCESS: 200,
- CREATED: 201,
- ACCEPTED: 202,
- NON_AUTHORITATIVE_INFORMATION: 203,
- NO_CONTENT: 204,
- BAD_REQUEST: 400,
- UNAUTHORIZED: 401,
- FORBIDDEN: 403,
- NOT_FOUND: 404,
- SERVER_ERROR: 500,
- });
}
diff --git a/src/utils/utils.js b/src/utils/utils.js
index dce456bd8..efe381f39 100644
--- a/src/utils/utils.js
+++ b/src/utils/utils.js
@@ -1,8 +1,20 @@
-export function priceFunc(p) {
- if (typeof p !== 'number') return 'NaN';
- return p.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+/**
+ * ์ฒ์ ์๋ฆฌ๋ง๋ค ,๋ฅผ ๋ถ์ฌ์ฃผ๋ ํจ์
+ *
+ * @param {number} price
+ * @returns {string}
+ */
+export function toPriceString(price) {
+ if (typeof price !== 'number') return 'NaN';
+ return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
+/**
+ * input์ด emptyํ์ง ํ์ธํด์ฃผ๋ ํจ์
+ *
+ * @param {*} input
+ * @returns {boolean}
+ */
export function isEmpty(input) {
if (
typeof input === 'undefined' ||
@@ -15,3 +27,94 @@ export function isEmpty(input) {
return true;
else return false;
}
+
+function isValidDateOrDateString(val) {
+ // NOTE ์ ํจํ Date ๊ฐ์ฒด์ธ๊ฐ?
+ if (val instanceof Date && !isNaN(val)) return true;
+
+ // NOTE ์ ํจํ ๋ ์ง ํ์์ string์ธ๊ฐ? - ์ด ๊ฒฝ์ฐ ISO 8601 ํ์์ธ๊ฐ?
+ if (typeof val === 'string') {
+ const timestamp = Date.parse(val);
+ if (!isNaN(timestamp)) {
+ const date = new Date(timestamp);
+ return date.toISOString().startsWith(val) || /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?)?$/.test(val);
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Date ๊ฐ์ฒด๋ ๋ ์ง ํ์ string์ '๋
. ์. ์ผ' ํ์์ผ๋ก ๋ณ๊ฒฝ
+ *
+ * @param {Date|string} value
+ * @returns {string}
+ */
+export function toDateString(value) {
+ if (!isValidDateOrDateString(value)) return null;
+
+ // NOTE value๊ฐ string์ด๋ฉด Date ๊ฐ์ฒด๋ก ๋ณํ
+ const date = value instanceof Date ? value : new Date(value);
+
+ // NOTE ๋
์ ์ผ ์ถ์ถ. ์ ์ผ์ ๊ฒฝ์ฐ 1์๋ฆฌ๋ฉด 0์ ์ฑ์ด๋ค.
+ 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}`;
+}
+
+/**
+ * Base64Url ํ์์ Base64 ํ์์ผ๋ก
+ *
+ * @param {string} b64u
+ * @returns {string}
+ */
+function b64uTob64(b64u) {
+ let b64 = b64u.split('-').join('+').split('_').join('/');
+
+ // NOTE Base64 ํ์์ 4์ ๋ฐฐ์ ๊ธธ์ด๋ฅผ ๊ฐ์ ธ์ผํจ.
+ while (b64 % 4 > 0) b64 += '=';
+
+ return b64;
+}
+
+/**
+ * JWT ํ ํฐ์ ํ์ฑํ๋ ํจ์
+ *
+ * @param {jwt} token
+ * @returns {{ header: object; payload: object; signature: object; }}
+ */
+export function parsingJWT(token) {
+ try {
+ // NOTE token์ ๊ตฌ๋ถ์(.)์ ์ด์ฉํด์ ์ชผ๊ฐฌ
+ const splittedToken = token.split('.');
+ if (splittedToken.length !== 3) throw new Error('input is not jwt');
+
+ // NOTE b64u ํ์์ธ ๊ฐ ํํธ๋ฅผ b64๋ก ๋ณ๊ฒฝํ ํ, decode
+ const header = JSON.parse(atob(b64uTob64(splittedToken[0])));
+ const payload = JSON.parse(atob(b64uTob64(splittedToken[1])));
+ const signature = splittedToken[2];
+
+ return { header, payload, signature };
+ } catch (err) {
+ console.error(err);
+ throw err;
+ }
+}
+
+/**
+ * jwt๋ฅผ ํ์ฑํด์ ํ ํฐ์ด ๋ง๋ฃ๋๋์ง ํ์ธํ๋ ํจ์
+ *
+ * @param {jwt} token
+ * @returns {boolean}
+ */
+export function isTokenExpired(token) {
+ // NOTE token์ด ์๊ฑฐ๋ jwt ํ ํฐ ํ์์ด์ง๋ง payload๋ exp๊ฐ ์์ผ๋ฉด ๋ง๋ฃ๋ ๊ฒ์ผ๋ก ๊ฐ์ฃผํ๋ค.
+ if (!token) return true;
+ const { payload } = parsingJWT(token);
+ if (!payload || !payload.exp) return true;
+
+ const currentTime = Math.floor(Date.now() / 1000); // Unix time ํ์์ ํ์ฌ ์๊ฐ
+ return payload.exp < currentTime;
+}
diff --git a/styles/button.css b/styles/button.css
index c097c9326..9b04e99ec 100644
--- a/styles/button.css
+++ b/styles/button.css
@@ -5,6 +5,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
+ font-family: 'Pretendard', sans-serif;
+ font-size: 1.6rem;
&:hover {
background-color: var(--Primary-200);